Reference · 15

Authorization and retention.

Authenticate callers, set access per method, and limit how long retry results are kept.

Authenticate and authorize

define({ auth }) and each method’s access compile into one access check. It runs before every public call, retry replay, cached read and watch refresh. Flower compiles it only when a call could be refused: when auth.authenticate or auth.delegation is set, or an exposed method’s access is a predicate. Otherwise every exposed method is public. The access guide walks through an example.

JWT sessions, one public method
import { define, jwtBearer, key, query } from "@flower-js/sdk";

const sessions = key("sessions", { algorithm: "Ed25519", usages: ["verify"] });
const me = query("me", (ctx) => ctx.principal());
const status = query("status", { access: "public" }, () => "ok");

export default define({
  auth: { authenticate: jwtBearer({ key: sessions, audience: ["api"] }) },
  http: { me, status },
});
APIContract
AuthConfigOptional authenticate; default, the access of methods that declare none ("authenticated" when authenticate is set, otherwise "public"); sessions, the access to retry sessions (defaults to default); and delegation(ctx, coordinator, principal), which can refuse a transaction coordinator (cluster peers are trusted by default).
Authenticate(ctx: QueryContext, credentials: Json, request: AuthorizationRequest) => Principal | null. Return null for an anonymous caller. fail() rejects the call with 403 FORBIDDEN and that failure.
Authenticator{ kind: "authenticator", keys, authenticate }: what jwtBearer returns. Its keys join the app’s keys.
jwtBearer(options): AuthenticatorVerifies bearer JWTs natively. Credentials may be the token, "Bearer …" or { token }; null means anonymous. Anything else, or a token that fails verification, fails with UNAUTHENTICATED. Managed-key trouble such as KEY_FORBIDDEN keeps its own code, since the token isn't at fault.
JwtBearerOptionsRequired key: a managed key or key version, raw HMAC bytes, or a PEM public key. Optional algorithms (required for raw keys), issuer, audience, clockToleranceSeconds, and principal(claims), which defaults to sub, a tenant claim if present, and all claims.
Access<A>A method’s access: "public", "authenticated" (UNAUTHENTICATED without a principal) or (ctx: QueryContext, principal, args) => boolean (FORBIDDEN on false). Predicates see arguments that passed the method’s schema, or fail with INVALID_ARGUMENT first.
Principal{subject: string, tenant?: string, claims?: Json}. subject and tenant must be nonempty, and $anonymous is reserved. In a named partition, tenant must equal the partition name.
AuthorizationRequest{credentials: Json, method: string, args: Json, partition: string|null, delegation: {coordinator:string,principal:Principal|null}|null}. method is the alias, or $flower.session.* for retry sessions. credentials defaults to null. delegation is set on participant calls from a transaction coordinator.
ctx.principal()The admitted principal in a query or mutation, or null for anonymous callers and apps without access checks. Not available in derived values or transaction plans.
HistoryIdentity{database:string,incarnation:string}, 128-bit hex. Stable across restarts and moves; changes only on a fenced disaster restore.
ctx.history()This database's HistoryIdentity, or null before retention is initialized. Queries and mutations only.
FlowerClientOptions.credentialsJSON credentials, or a (possibly async) function returning them per call. RequestOptions.credentials overrides it for one request. A watch keeps its credentials until it closes; subscribe asks the function again on every reconnect.
  • Credentials are separate from args. A retry with refreshed credentials for the same subject and tenant gets the original result; a different caller can't.
  • Anonymous callers are admitted as subject $anonymous (with the partition as tenant in a named partition), so their receipts never mix with a real subject's.
  • The check reads a fresh snapshot before the call runs. Recheck state that can change in between inside the mutation.
  • An app with an access check reads fresh policy on every call, so even replica-local methods need a quorum. Cached reads also wait for full admission, and some write batching is skipped.
  • Watches recheck on each refresh and close when denied. Data already sent can't be taken back.
  • A trusted coordinator’s principal is admitted in each participant partition with that partition’s name as its tenant.
  • define throws if a method, or auth.sessions, needs "authenticated" access and there is no authenticate.
  • Authorization doesn't secure peer or admin traffic. Protect the network too.

Limit how long retry results are kept

By default Flower keeps every retry result forever. Retention lets you drop old ones. It's opt-in and uses numbered epochs, not clock time:

  1. Initialize retention once.
  2. Issue request IDs scoped to the current epoch.
  3. Advance the minimum epoch. Older request IDs are then rejected for good.
  4. Collect the retired results in batches.
APIContract
RetryIdentity{database,incarnation,currentEpoch,minEpoch}. IDs are 32 lowercase hex characters; epochs are nonnegative integers.
RetentionStateRetryIdentity plus receiptBytes, receiptCount, maxReceiptBytes (number|null), gcCursor (string|null), gcComplete, sessionBytes, sessionCount, gcReceiptsComplete and gcSessionCursor.
RetentionActionOne of: initialize {database,incarnation,max_receipt_bytes}; advance {incarnation,current_epoch,min_epoch}; collect {incarnation,limit}; set_budget {incarnation,max_receipt_bytes}; reincarnate {incarnation,new_incarnation,fence_attestation}, each with operation. Epochs never go down. reincarnate is for disaster restore only, after you have fenced the old cluster yourself.
admin.retentionStatus(options?)Current RetentionState, or null. Admin token.
admin.controlRetention(expectedRevision, action, options?)Apply an action if the revision matches. Returns {state, collected}. After a lost response, check status before sending anything else.
client.refreshRetryIdentity(options?)Reload the current epoch for new request IDs. Doesn't update old IDs. Fails if retention isn't initialized.
client.newRequestId(intent?, options?)Make an epoch-scoped request ID (random intent by default). Save it before sending and reuse it on retry. The same intent in a new epoch is a new request.
RetrySession{database,incarnation,id,epoch,acknowledgedThrough,closed}, owned by the caller's principal. Needs access checks: auth.sessions decides who may use sessions, and the check sees $flower.session.open, .status, .ack and .close.
SessionOptionsRequestOptions plus limit? (default 256 records per call) and abandon? (default false, ACK only).
client.openRetrySession(id?, options?)Open a session. Pick and save your own 32-character hex ID first so you can recover a lost response. Reopening an active session is safe; closed ones can't be reopened.
client.sessionRequestId(session, sequence)Request ID for a sequence number above the ACK mark. You track sequence numbers; the SDK never advances or ACKs for you.
client.retrySessionStatus({id,incarnation}, options?)Current session state, for the owner only. Use it after an uncertain ACK.
client.acknowledgeRetrySession(session, through, options?)Drop results up to through. ACK only after you've saved them. Every result up to that point must exist, unless abandon: true, which also cancels unknown outcomes for good. Retries at or below the mark return ALREADY_ACKNOWLEDGED and never run again.
client.closeRetrySession(session, options?)Close the session for good. In-flight results may be lost. The ID can't be reused.
FlowerClientOptions.boundedRetriesDefault false. When true, generated request IDs use the current epoch. Your own IDs pass through unchanged. The SDK doesn't refresh the epoch for you.

Errors to handle:

  • RETRY_WINDOW_EXPIRED: the result is gone. The mutation may still have committed.
  • HISTORY_MISMATCH: the ID belongs to a different database history.
  • RECEIPT_BUDGET_EXCEEDED: new work is rejected; kept results are never evicted.

Once initialized, unscoped request IDs are rejected. A disaster restore needs a new incarnation; restoring an old backup as-is is unsafe. Backups may still contain deleted results. Details: retention protocol.

Cleaning up cross-group transactions

Records of finished cross-group transactions are removed only when you close and collect them. Prepared transactions without a decision are never released.

APIContract
TransactionClosureTarget{group,partition:string|null,epoch,addresses?:string[]}. Placement info; not used for routing.
TransactionClosureState{history:string|null,nextSequence,closedThrough,pending,blockedReason:string|null,deletedRecords}. pending is null or {through,participants,acknowledged}. blockedReason explains a stall, such as an unfinished transaction or an unreachable peer.
TransactionClosureAction{operation:"close",through?:number,maxBytes?:number} or {operation:"collect",maxBytes?:number}. through defaults to all finished transactions; maxBytes defaults to the node's transaction budget.
admin.transactionClosureStatus(options?)Current closure state. Use admin.partition(name) for a named database.
admin.controlTransactionClosure(action, options?)close tells participants to reject late messages, then advances the floor. collect deletes old records; run it on coordinators and participants. Safe to repeat. Check pending and blockedReason, not just HTTP success.

An aborted transaction can close only once its request ID can no longer be retried, so old unscoped aborts block closure until retention is initialized.