AuthMiddleware.install(ctx, config, source) now owns the annotation processor and
the flash.auth.policy key, so a second credential source gets annotation-driven
authorization without copying the wiring. The key is public: an extension that
contributes middleware can order itself around authentication.
OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque
attribute map. OpenID Connect keeps its access, id and refresh tokens in that map
under its own keys, so renewal stays its business and core has no OAuth2 vocabulary
in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal
window it always had and now a test for it.
flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine
and data-core/data-hibernate.
flash-ext-oidc has always held two things: the OpenID Connect protocol, and a
session/claims/authorization layer that is generic and was only ever fed by one
source. This splits them along Flash's own <domain>-core convention, the same
shape cache-core, data-core and view-core already use.
flash-ext-auth-core gets what never referenced the protocol — @Authenticated,
@RolesAllowed, @ScopesAllowed, ClaimsHolder, the claim matching, and the policy
compiled from annotations — under generic names: OidcUser is Claims, since it
never was more than a typed view over a claims map, and OidcAuthPolicy is
AuthPolicy. It was package-private while being the parameter type of a public
method, so the move also fixes that.
The new seam is CredentialSource: it resolves a request's claims, or rejects the
request the way its protocol says to. AuthMiddleware publishes the result and
matches roles and scopes against it. ClaimsHolder's writers stay package-private
— an implementation produces claims and core publishes them, so nothing outside
this module can put claims on a request that did not carry them.
flash-ext-oidc keeps discovery, JWKS, PKCE, the token endpoint, the login and
callback routes and the OpenAPI oauth2 contributor, and now registers
OidcCredentialSource. flash-ext-mcp still keys McpSecurity on finding that type
and not on AuthMiddleware: REQUIRED has to keep meaning "a real authorization
server is protecting this endpoint", not "something authenticates here".
The middleware key moves with the mechanism: flash.oidc.policy -> flash.auth.policy.
Breaking for consumers: imports move to dev.relism.flash.ext.auth, OidcMiddleware
becomes AuthMiddleware, ClaimsHolder.user()/get() become current()/map().
Split the way flash-ext-data and flash-ext-view are: cache-core defines
Cache, CacheManager, CacheSpec and CacheStats and talks to nothing;
cache-caffeine implements them in process.
users = require(CacheManager.class).build("users", spec -> spec
.maxSize(10_000).ttl(Duration.ofMinutes(10)));
return users.get(id, repo::findById);
get(key, loader) is the only shape most code needs and the only one that is
hard to get right: the loader runs once per key across concurrent callers
rather than each racing its own. A null result stores nothing, because caching
absence is a decision rather than a default.
build(name, spec) is idempotent per name, so two handlers wanting one cache get
one cache without coordinating who creates it. Disagreeing about the spec
throws rather than resolving to whichever handler initialised first, which is a
bug that only surfaces under load.
recordStats() is opt-in — counting is two atomic increments per lookup, and a
cache nobody measures should not pay for numbers nobody reads. Unmeasured
caches return CacheStats.DISABLED rather than zeroes that look like a cold
cache.
Caffeine rather than a hand-rolled LRU: for genuinely low traffic
ConcurrentHashMap::computeIfAbsent is one line and needs no module at all, and
this exists for when that stops being true. W-TinyLFU admission, striped
counters and amortised eviction are not a weekend's work, and getting them
wrong yields a cache slower than no cache. The adapter is deliberately thin —
every method delegates, adding no wrapper, copy or locking of its own.
Caches are dropped through FlashContext.onClose, so values do not outlive the
app holding them. Invisible with one app per process; immediate under test.
flash-ext-cache-redis is designed but not built, and has docs only — no module,
no pom, no source. An empty module that builds an empty jar is dead weight in
the reactor. The docs record what changes once the cache can fail: get() must
decide whether to fall through to the loader, values need a codec,
invalidateAll needs a key prefix that becomes wire contract, and eviction stats
stop meaning anything. Those are decisions that want a real second replica to
check them against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jobs.every(Duration.ofMinutes(5), reports::refresh);
jobs.cron("0 0 3 * * *", archive::sweep);
One daemon platform thread keeps time; every job body runs on a virtual thread,
so a slow job delays nothing but its own next run and cannot occupy the timer.
Cron expressions compile once into a bitmask per field — a long for seconds and
minutes, an int for the rest — so matching an instant is a shift and a mask
rather than a parse or a set lookup. Next-fire advances by the largest unit
that cannot match instead of ticking second by second, so a yearly expression
resolves in a few dozen iterations rather than thirty million. Five or six
fields, ranges, steps, lists, named months and weekdays, both Sunday encodings,
and the standard union semantics when both day fields are restricted. A
malformed expression throws when the job is registered, not when it would have
fired.
Overlapping runs are skipped, and deliberately not configurable: two copies of
one job at once is a bug in every case anyone has needed, and a flag would only
let it be set wrongly. A skipped run logs how long the previous one has been
going.
A throwing job is logged and keeps its schedule. scheduleAtFixedRate cancels
the task on first exception, silently — a job that dies at 3am and is never
heard from again is the failure this avoids.
Shutdown goes through FlashContext.onClose, so app.stop() drains HTTP first,
then gives running jobs a grace period before forcing them down. Nothing runs
after the app stops. The grace period is the only knob.
Known ceiling, marked in the source: schedules are per-instance, so two
replicas run every job twice. A distributed lock should not exist until there
is a second replica.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Standard jakarta.validation annotations, compiled once per type into a flat
check table. No configuration: constraints come from the annotations already on
your types, and ValidationException extends HttpException with status 422 so
the default handler renders it without this extension registering anything.
record CreateUser(@NotBlank @Size(max = 80) String name,
@Email String email,
@Min(18) int age) {}
CreateUser dto = validation.body(req, CreateUser.class);
Annotations only — Hibernate Validator's engine is deliberately absent. It
resolves constraints reflectively per call and pulls ~2 MB plus EL, which is
the per-request cost this module exists to avoid. jakarta.validation-api is
~90 KB of annotations.
The passing path allocates nothing. Constraints resolve at first use into an
opcode plus operands cached in a ClassValue, so there is no map lookup and no
lock. Fields are read through MethodHandles adapted to an exact signature —
(Object)Object for references, (Object)long for primitive integrals — so
invokeExact neither boxes nor builds the argument array Field.get and
Method.invoke allocate. Checks are a flat array walked by a tableswitch rather
than a class hierarchy behind a virtual call. @Size reads a length the object
already knows and @Email scans with indexOf, because Pattern.matcher allocates
a matcher and two int arrays per call. Messages are pre-rendered at compile
time. The violation list and the exception exist only once something fails.
@Pattern is the marked exception: its regex compiles once but matcher()
allocates per call.
Constraints are read from declared fields, so records and plain classes take
one code path — a constraint on a record component propagates to its backing
field.
Jakarta null semantics are exact: only @NotNull rejects null.
flash-ext-openapi now mirrors the same annotations into the generated schema —
minLength, maxLength, minItems, minimum, maximum, pattern, format: email and
required — via an optional jakarta.validation dependency detected at boot. A
type declares its rules once and both the validator and the published contract
read them. An explicit @Schema still wins; the bridge only fills keys nobody
set, and without the annotations on the classpath the bridge class is never
loaded.
flash-ext-jackson is optional too: validate(value) works without it, only
body(req, type) needs a codec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Boots a real app on an OS-assigned port for a test class or a single test and
hands back a client pointed at it:
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
app.get("/api/users").expectStatus(200).expectBodyContains("alice");
A field rather than an annotation, because annotation values are compile-time
constants and so could never express a second server wired from the first —
FlashTest.of(new BlogApp(auth.baseUri())). Startup is lazy, so reading
baseUri() boots that server on the spot and declaration order does the wiring,
with no dependence on JUnit's extension ordering. A static field boots once per
class, a non-static field once per test; that is stock JUnit field semantics
rather than an option to configure.
Runs against a real loopback port instead of dispatching in-process. An
in-process dispatcher would be a third copy of the routing/handler/exception
sequence that Http1Connection and Http2StreamDispatcher already duplicate, kept
in sync by hand, and it would let a test pass while the status line,
content-length or HPACK encoding was broken.
mock() installs overrides as the last extension, after everything the
application and its extensions declare, so a fake always wins. Any object is
accepted, so a hand-written fake and a Mockito mock are equally welcome and
this module depends on no mocking library — only flash and junit-jupiter-api.
Teardown cancels the client before stopping the server: HttpClient holds
keep-alive sockets open and ServerLifecycle.stop() spins until the last one
closes, so the default 15s drain would otherwise be paid on every test class.
shutdownNow rather than close(), which blocks until every operation completes
and would hang on a leaked WebSocket.
Lives at the top level, not under flash-extensions/, which holds things you
install() onto an app; this carries junit-jupiter-api at compile scope and
nothing installable should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its maven-deploy-plugin was configured with <skip>true</skip>, so the
first successful publish-maven.yml run deployed flash-parent, flash,
and every flash-ext-* jar to Gitea's Maven registry, but not
flash-extensions itself — the pom-packaging aggregator every
flash-ext-* submodule's effective POM inherits from via <parent>.
Remote consumers (Pathway's Docker build, resolving flash-ext-* from
the registry instead of a local ~/.m2 install) couldn't resolve that
parent POM and failed with "artifacts could not be resolved:
flash-extensions:pom (absent)".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Streamable HTTP transport (JSON-RPC 2.0 over POST), one-class-per-tool/resource/prompt
API mirroring RequestHandler, boot-time-precompiled schema/list payloads for a zero-alloc
hot path, and optional OAuth2 protection built on flash-ext-oidc (lazy-loaded, RFC 8707
audience binding, RFC 9728 Protected Resource Metadata). Registers the module in the
root and flash-extensions POMs and adds the ext-mcp commit scope to AGENTS.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>