@voltro/plugin-auth 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AuthStrategy } from '@voltro/protocol';
2
+ import { ConnectionCredential } from '@voltro/protocol';
2
3
  import { Effect } from 'effect';
3
4
  import { SessionSecrets } from '@voltro/protocol/session';
4
5
  import { Subject } from '@voltro/protocol';
@@ -63,6 +64,17 @@ declare interface AuthConfig {
63
64
  /** Injectable clock (epoch-ms) for the lockout counter. Default `Date.now`;
64
65
  * tests pass a controllable one to exercise window/lock expiry. */
65
66
  readonly now?: () => number;
67
+ /**
68
+ * Email verification. OFF by default (`policy: 'off'`) — the endpoints and
69
+ * the `emailVerifiedAt` column exist, nothing is gated.
70
+ *
71
+ * `'strict'` is enforced through `subjectGuards`, so it covers every path
72
+ * that issues a session; `authRoutesPlugin` installs that guard for you.
73
+ * Hand-wiring the handlers? Add `emailVerificationGuard(config)` to
74
+ * `subjectGuards` yourself — setting the policy alone changes only what the
75
+ * session is MARKED with, not who may log in.
76
+ */
77
+ readonly emailVerification?: EmailVerificationConfig;
66
78
  }
67
79
 
68
80
  /**
@@ -97,13 +109,36 @@ export declare interface AuthRoutesPluginOptions extends AuthConfig {
97
109
  * enrolment routes. */
98
110
  readonly mfa?: MfaConfig;
99
111
  /** Rebind hook for switch-tenant on the live WS path — pass
100
- * `bindConnectionSubject` from `@voltro/runtime`. */
112
+ * `bindConnectionCredential` from `@voltro/runtime`. It receives the cookie
113
+ * this plugin just minted, not a Subject: the connection then presents the
114
+ * same credential a reconnecting browser would, and the auth chain judges it
115
+ * the same way (revocation included). */
101
116
  readonly rebind?: RebindConnection;
102
117
  /** Route prefix. Default `/auth`. */
103
118
  readonly prefix?: string;
104
119
  /** Tuning for the request-time session-revocation check (TTL cache
105
120
  * window, default 30s; `now` is a testing seam). */
106
121
  readonly sessionRevocation?: SessionRevocationOptions;
122
+ /**
123
+ * Tenant invitations. PRESENT (even as `{}`) mounts the five
124
+ * `/auth/invitations/*` routes and arms the retention bound on the
125
+ * `invitations` table; ABSENT mounts nothing.
126
+ *
127
+ * Opt-in by presence, like `mfa` and `passkey`, and for the same reason:
128
+ * the table ships in `authTables` but an app that has not spread it would
129
+ * otherwise get 500s from a route it never asked for.
130
+ */
131
+ readonly invitations?: InvitationsConfig;
132
+ /**
133
+ * User impersonation ("log in as"). PRESENT mounts `/auth/impersonate/start`
134
+ * + `/stop`, arms the retention bound on `impersonationGrants`, and closes
135
+ * the account-security routes to impersonated sessions; ABSENT mounts
136
+ * nothing and the block below cannot fire.
137
+ *
138
+ * Both `authority` and `audit` are REQUIRED — there is no way to switch this
139
+ * on while leaving either unanswered. See `ImpersonationConfig`.
140
+ */
141
+ readonly impersonation?: ImpersonationConfig;
107
142
  /** Disambiguates multiple instances of this plugin in one app. */
108
143
  readonly name?: string;
109
144
  }
@@ -115,6 +150,45 @@ declare interface ChallengeStore {
115
150
  readonly take: (key: string) => Effect.Effect<string | null>;
116
151
  }
117
152
 
153
+ declare interface EmailVerificationConfig {
154
+ /** Default `'off'` — see the header for why a stricter default is an
155
+ * outage rather than a safer choice. */
156
+ readonly policy?: EmailVerificationPolicy;
157
+ /** Lifetime of a verification link. Default {@link EMAIL_VERIFY_TTL_S} (24h). */
158
+ readonly ttlSeconds?: number;
159
+ /** Path the emailed link points at. Default `/auth/verify-email/callback`. */
160
+ readonly callbackPath?: string;
161
+ /**
162
+ * Minimum gap between two verification emails to the same user. Default 60s.
163
+ *
164
+ * The only bound on an endpoint that sends mail to an address chosen by an
165
+ * unauthenticated caller. Without it, "resend" is a mail-bomb primitive
166
+ * pointed at any address the attacker knows has an account, and the cost is
167
+ * carried by your sending reputation.
168
+ */
169
+ readonly resendCooldownSeconds?: number;
170
+ /** Send the verification email automatically on sign-up. Default: true
171
+ * whenever `policy` is not `'off'`. */
172
+ readonly sendOnSignUp?: boolean;
173
+ /**
174
+ * Accounts created STRICTLY BEFORE this instant count as verified.
175
+ *
176
+ * The migration seam, and the reason `'strict'` is adoptable at all: an app
177
+ * turning the policy on has a table full of `emailVerifiedAt IS NULL` rows
178
+ * belonging to real users who did nothing wrong. Set this to the deploy
179
+ * instant and only accounts created from then on have to prove anything.
180
+ *
181
+ * It is deliberately a CONFIG value rather than a backfill we run for you:
182
+ * an app that genuinely wants its existing users to re-verify says so by
183
+ * leaving this unset, and one that does not is spared writing to every row
184
+ * of its users table.
185
+ */
186
+ readonly exemptAccountsCreatedBefore?: Date;
187
+ }
188
+
189
+ /** What an account with an unproven address may do. See the header. */
190
+ declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict';
191
+
118
192
  /**
119
193
  * Pull the pre-wired session strategy off a plugin list (the framework's
120
194
  * serve pipeline calls this when composing the auth chain). Returns the
@@ -122,6 +196,165 @@ declare interface ChallengeStore {
122
196
  */
123
197
  export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
124
198
 
199
+ declare interface GrantRoleContext {
200
+ /** The inviter's role in THIS tenant, as read from `memberships`. */
201
+ readonly inviterRole: string;
202
+ /** The role the invitation would carry. */
203
+ readonly requestedRole: string;
204
+ readonly tenantId: string;
205
+ }
206
+
207
+ declare interface ImpersonationAuditEvent {
208
+ readonly kind: ImpersonationAuditKind;
209
+ readonly at: Date;
210
+ /** The impersonator. Null only when the attempt had no resolvable caller. */
211
+ readonly actorUserId: string | null;
212
+ readonly actorEmail: string | null;
213
+ /** The impersonated user. Null on a refusal that never resolved one. */
214
+ readonly targetUserId: string | null;
215
+ readonly targetEmail: string | null;
216
+ readonly tenantId: string | null;
217
+ /** The impersonated session, once one exists. */
218
+ readonly sessionId?: string;
219
+ readonly grantId?: string;
220
+ readonly reason?: string | null;
221
+ readonly expiresAt?: Date;
222
+ /** The machine code on `kind: 'refused'` — `self`, `nested`, `not_permitted`,
223
+ * `target_may_impersonate`, `reason_required`, `target_not_found`. */
224
+ readonly refusal?: string;
225
+ readonly ipAddress?: string | null;
226
+ readonly userAgent?: string | null;
227
+ }
228
+
229
+ /** What happened, for the audit sink. `refused` is recorded too: an attempt
230
+ * that was turned away is the single most interesting row in this table. */
231
+ declare type ImpersonationAuditKind = 'started' | 'stopped' | 'refused';
232
+
233
+ declare interface ImpersonationAuthorityInput {
234
+ /** The caller's resolved subject. */
235
+ readonly actor: Subject;
236
+ /** The caller's user row. */
237
+ readonly actorUser: UserRecord;
238
+ /** The user they are asking to act as. */
239
+ readonly target: UserRecord;
240
+ }
241
+
242
+ declare interface ImpersonationConfig {
243
+ /**
244
+ * MAY this actor impersonate this target? REQUIRED — see the header for why
245
+ * there is no default and why it must not be a scope check.
246
+ *
247
+ * It is called TWICE per start: once as asked, and once with the identities
248
+ * SWAPPED to answer "could the target impersonate the actor?". A `true` from
249
+ * the second call refuses the request (the PEER rule). Write it as a pure
250
+ * predicate over the two records; a function whose answer depends on which
251
+ * request is in flight will make that probe meaningless.
252
+ */
253
+ readonly authority: (input: ImpersonationAuthorityInput) => Effect.Effect<boolean>;
254
+ /**
255
+ * Where impersonation events go. REQUIRED, and required precisely because
256
+ * this is the feature whose whole risk is an unrecorded action: a config
257
+ * that let you enable impersonation while leaving the destination unset
258
+ * would make the dangerous half optional and the safe half opt-in.
259
+ *
260
+ * Called for `started`, `stopped` AND `refused`. A throwing or rejecting
261
+ * sink is swallowed — a broken log must not leave a grant half-created —
262
+ * so treat this as best-effort ON TOP of the `impersonationGrants` row,
263
+ * which is written transactionally with the session and is not.
264
+ */
265
+ readonly audit: (event: ImpersonationAuditEvent) => Effect.Effect<void> | Promise<void> | void;
266
+ /**
267
+ * Hard ceiling on one grant, in seconds. Default 900 (15 minutes).
268
+ *
269
+ * A caller may request LESS and never more — a requested duration is
270
+ * clamped, not validated, because a support tool asking for eight hours
271
+ * should get fifteen minutes rather than an error it will retry around.
272
+ * The number is the impersonated cookie's TTL as well as the grant row's
273
+ * `expiresAt`, so the bound survives the process that issued it.
274
+ */
275
+ readonly maxDurationSeconds?: number;
276
+ /** Require a non-empty `reason` on every start. Default `true` — a support
277
+ * action with no stated cause is the one you cannot review later. */
278
+ readonly requireReason?: boolean;
279
+ }
280
+
281
+ /** One impersonation grant — "actor is acting as target, on this session,
282
+ * until this instant". See `impersonationGrantsTable`. */
283
+ declare interface ImpersonationGrantRecord {
284
+ readonly id: string;
285
+ /** The IMPERSONATED session's row id. */
286
+ readonly sessionId: string;
287
+ readonly actorUserId: string;
288
+ /** The impersonator's own session, restored when the grant ends. */
289
+ readonly actorSessionId: string;
290
+ readonly targetUserId: string;
291
+ readonly tenantId: string;
292
+ readonly reason: string | null;
293
+ readonly startedAt: Date;
294
+ readonly expiresAt: Date;
295
+ readonly endedAt: Date | null;
296
+ readonly endedReason: string | null;
297
+ readonly ipAddress?: string | null;
298
+ readonly userAgent?: string | null;
299
+ }
300
+
301
+ /**
302
+ * A tenant invitation — the grant that turns into a `memberships` row when
303
+ * redeemed. See `invitationsTable` for why `role` lives here and not in the
304
+ * accept request.
305
+ */
306
+ declare interface InvitationRecord {
307
+ readonly id: string;
308
+ /** SHA-256 hex of the plaintext token. The plaintext lives only in the email. */
309
+ readonly tokenHash: string;
310
+ /** The invited address, lower-cased. */
311
+ readonly email: string;
312
+ readonly tenantId: string;
313
+ /** The role the membership is created with. Chosen by the INVITER. */
314
+ readonly role: string;
315
+ readonly invitedByUserId: string;
316
+ readonly expiresAt: Date;
317
+ readonly acceptedAt: Date | null;
318
+ readonly acceptedByUserId: string | null;
319
+ readonly revokedAt: Date | null;
320
+ readonly revokedByUserId: string | null;
321
+ readonly createdAt: Date;
322
+ }
323
+
324
+ declare interface InvitationsConfig {
325
+ /** Lifetime of an invitation. Default {@link INVITATION_TTL_S} (7 days). */
326
+ readonly ttlSeconds?: number;
327
+ /** Path the emailed link points at. Default `/auth/invitations/accept`. */
328
+ readonly callbackPath?: string;
329
+ /**
330
+ * Cap on simultaneously-live invitations per tenant. Default 500.
331
+ *
332
+ * A bound, not a business rule: a compromised admin account otherwise turns
333
+ * the invite endpoint into a mail cannon aimed at any list of addresses, one
334
+ * row and one message each, with your domain as the sender.
335
+ */
336
+ readonly maxPending?: number;
337
+ /** Roles permitted to create / list / revoke invitations. Default
338
+ * `['owner', 'admin']`. */
339
+ readonly inviterRoles?: ReadonlyArray<string>;
340
+ /** Role ordering, most privileged FIRST. Feeds the default
341
+ * {@link InvitationsConfig.canGrantRole}. Default
342
+ * `['owner','admin','member','viewer']`. */
343
+ readonly roleRank?: ReadonlyArray<string>;
344
+ /**
345
+ * Replace the whole "may this inviter hand out this role" rule.
346
+ *
347
+ * The default ranks by {@link InvitationsConfig.roleRank} and permits an
348
+ * inviter to grant their own role or any role BELOW it; a requested role
349
+ * absent from the rank list may be granted only by the top-ranked role, and
350
+ * an inviter whose own role is absent may grant nothing. Supply your own for
351
+ * any model that is not a line — a matrix, a per-tenant plan, a capability
352
+ * set. Whatever you write, keep it a refusal by default: this function is
353
+ * the only thing between an admin and self-promotion by invitation.
354
+ */
355
+ readonly canGrantRole?: (ctx: GrantRoleContext) => boolean;
356
+ }
357
+
125
358
  /**
126
359
  * Brute-force lockout policy, resolved from `AuthConfig.lockout` and passed
127
360
  * INTO the store on each failure so the store itself stays policy-free.
@@ -179,9 +412,18 @@ declare interface PasskeyRecord {
179
412
  readonly lastUsedAt: Date | null;
180
413
  }
181
414
 
182
- /** Injected rebinder — the app passes `bindConnectionSubject` from
183
- * `@voltro/runtime` so the plugin stays runtime-agnostic. */
184
- declare type RebindConnection = (clientId: number, subject: ReturnType<typeof subjectFromUser>) => void;
415
+ /**
416
+ * Injected rebinder — the app passes `bindConnectionCredential` from
417
+ * `@voltro/runtime` so the plugin stays runtime-agnostic.
418
+ *
419
+ * It takes the CREDENTIAL this handler just minted, not the Subject it built.
420
+ * The Subject form was a fast path around the entire auth chain: the middleware
421
+ * returned the stored value directly, so the connection never re-ran the
422
+ * session-revocation check, `resolveScopes` or the scope cache again. Handing
423
+ * the cookie over instead means the connection presents the same credential a
424
+ * reconnecting browser would, and is judged the same way.
425
+ */
426
+ declare type RebindConnection = (clientId: number, credential: ConnectionCredential) => void;
185
427
 
186
428
  declare type SendEmail = (input: SendEmailInput) => Promise<void>;
187
429
 
@@ -194,7 +436,7 @@ declare interface SendEmailInput {
194
436
  readonly html: string;
195
437
  readonly text: string;
196
438
  /** Discriminates the flow so a custom sender can branch. */
197
- readonly kind: 'magic-link' | 'password-reset';
439
+ readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
198
440
  /** The action URL embedded in the email (also present inside `html`). */
199
441
  readonly actionUrl: string;
200
442
  }
@@ -227,44 +469,6 @@ declare interface SessionRevocationOptions {
227
469
  readonly now?: () => number;
228
470
  }
229
471
 
230
- /**
231
- * Convert a UserRecord into the protocol Subject the framework
232
- * carries around per-request. Tenant id flows through; user type
233
- * is locked to 'user'.
234
- *
235
- * The user's tenant memberships, when supplied, are carried in
236
- * `metadata.memberships` (the protocol Subject's metadata slot — the
237
- * framework never reads it, but app code + the switch-tenant menu do).
238
- * Pass an explicit `tenantId` to make the ACTIVE tenant differ from the
239
- * user's home tenant (post switch-tenant rebind).
240
- *
241
- * `metadata` carries anything else the app needs on the Subject — most
242
- * often a provider credential captured at login and read back by a
243
- * plugin's `credentialsResolver` (`subject.metadata.jiraToken` for
244
- * `@voltro/plugin-atlassian`, say). Without it an app that needs such a
245
- * credential could not adopt this helper at all: building the Subject by
246
- * hand was the only way to keep the value.
247
- *
248
- * **Precedence, when a `memberships` key appears in BOTH:** the dedicated
249
- * `memberships` option wins. It is the specific, typed input and it is
250
- * projected to the `{ tenantId, role }` shape `subjectMemberships` reads,
251
- * so letting a free-form bag silently shadow it would break the
252
- * switch-tenant menu in a way nothing type-checks. A `memberships` key
253
- * inside `metadata` is passed through UNCHANGED when the option is absent
254
- * — no reshaping, no validation.
255
- *
256
- * Note this only stamps the metadata slot at CONSTRUCTION. The sign-in /
257
- * sign-up / magic-link / passkey handlers later merge `sessionId` (and
258
- * the password strategy merges `provider`) onto whatever is here, so keys
259
- * set through this option survive those paths — unless you name a key
260
- * `sessionId` or `provider`, which those merges overwrite by design.
261
- */
262
- declare const subjectFromUser: (user: UserRecord, options?: {
263
- readonly tenantId?: string;
264
- readonly memberships?: ReadonlyArray<MembershipRecord>;
265
- readonly metadata?: Record<string, unknown>;
266
- }) => Subject;
267
-
268
472
  /**
269
473
  * A post-authentication subject guard: given the authenticated user,
270
474
  * decide whether the login may proceed. Runs after the credential check,
@@ -283,7 +487,7 @@ declare type SubjectGuardVerdict = {
283
487
  readonly message: string;
284
488
  };
285
489
 
286
- declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
490
+ declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify';
287
491
 
288
492
  /** A single-use, hashed, expiring token (magic-link / password-reset). */
289
493
  declare interface TokenRecord {
@@ -343,6 +547,18 @@ declare interface UserRecord {
343
547
  * verify). Distinct from the secret being set — a half-completed
344
548
  * enrolment leaves `mfaSecret` populated but this null. */
345
549
  readonly mfaEnrolledAt?: Date | null;
550
+ /**
551
+ * When this address was PROVEN to reach its owner — by redeeming a
552
+ * verification link, a magic link, or a password-reset link. `null` /
553
+ * absent = unproven.
554
+ *
555
+ * A store whose backing table predates the `emailVerifiedAt` column simply
556
+ * leaves it undefined, which reads as unverified — and that is why
557
+ * `emailVerification.policy` defaults to `'off'`: under any stricter
558
+ * default, "the column is not there yet" and "this user never confirmed"
559
+ * would both refuse the login.
560
+ */
561
+ readonly emailVerifiedAt?: Date | null;
346
562
  /** Optional lifecycle flag read by post-authentication subject guards.
347
563
  * Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
348
564
  * deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
@@ -371,6 +587,15 @@ declare interface UserStore {
371
587
  /** Wipe the secret + enrolledAt. Used by /account/security's
372
588
  * "remove MFA" action. */
373
589
  readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
590
+ /**
591
+ * Stamp `emailVerifiedAt` — the address has been proven.
592
+ *
593
+ * IDEMPOTENT and non-regressing: a user already verified keeps their
594
+ * ORIGINAL timestamp. "When was this address first proven" is the question
595
+ * the column answers, and re-stamping it on every later magic-link login
596
+ * would quietly turn it into "when did they last click something".
597
+ */
598
+ readonly markEmailVerified: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
374
599
  /** Every tenant the user belongs to. */
375
600
  readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
376
601
  /** Idempotent grant: inserts the [userId, tenantId] membership or
@@ -408,6 +633,52 @@ declare interface UserStore {
408
633
  * already consumed — and marks it consumed in the same step. Returns
409
634
  * null otherwise (single-use guard). */
410
635
  readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
636
+ /**
637
+ * The most recently CREATED token of this purpose for the user, consumed or
638
+ * not — or null when there is none.
639
+ *
640
+ * Exists for the resend cooldown, which is the only bound on an endpoint
641
+ * that sends mail to an address supplied by an unauthenticated caller. Note
642
+ * it deliberately does NOT filter on `consumedAt`: the cost being rate-
643
+ * limited is the EMAIL, and a token that was minted and then redeemed cost
644
+ * exactly as much to send as one still pending.
645
+ */
646
+ readonly latestToken: (userId: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
647
+ /** Persist an invitation (token already hashed). */
648
+ readonly insertInvitation: (invitation: Omit<InvitationRecord, 'acceptedAt' | 'acceptedByUserId' | 'revokedAt' | 'revokedByUserId' | 'createdAt'>) => Effect.Effect<InvitationRecord>;
649
+ /** Look an invitation up by token hash WITHOUT redeeming it — the read the
650
+ * "what am I being invited to" preview needs. Returns the row whatever its
651
+ * state; the caller decides what an expired or revoked one means. */
652
+ readonly findInvitationByHash: (tokenHash: string) => Effect.Effect<InvitationRecord | null>;
653
+ /**
654
+ * Atomically redeem an invitation: returns the row only when it exists, is
655
+ * unaccepted, unrevoked and unexpired — and stamps `acceptedAt` +
656
+ * `acceptedByUserId` in the same step. Null otherwise.
657
+ *
658
+ * The single-use guard, and it has to be ONE statement: two clicks on the
659
+ * same emailed link race, and a read-then-write would let both through and
660
+ * write the membership twice.
661
+ */
662
+ readonly acceptInvitation: (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect<InvitationRecord | null>;
663
+ /** Withdraw a PENDING invitation, scoped to its tenant so an admin of one
664
+ * tenant cannot revoke another's by id. Returns whether a row changed. */
665
+ readonly revokeInvitation: (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect<boolean>;
666
+ /** Every invitation for a tenant, newest first. The admin list. */
667
+ readonly listInvitations: (tenantId: string) => Effect.Effect<ReadonlyArray<InvitationRecord>>;
668
+ /** How many invitations for this tenant are still redeemable — the bound
669
+ * `maxPending` enforces. */
670
+ readonly countPendingInvitations: (tenantId: string, now: Date) => Effect.Effect<number>;
671
+ /** Revoke every pending invitation for (tenant, email). Called before
672
+ * issuing a fresh one so re-inviting supersedes rather than accumulates —
673
+ * otherwise every resend leaves a live token behind. Returns the count. */
674
+ readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
675
+ /** Record an impersonation grant when the impersonated session is minted. */
676
+ readonly insertImpersonationGrant: (grant: Omit<ImpersonationGrantRecord, 'startedAt' | 'endedAt' | 'endedReason'>) => Effect.Effect<ImpersonationGrantRecord>;
677
+ /** The grant for an impersonated session, or null. The stop path's lookup. */
678
+ readonly findImpersonationGrantBySession: (sessionId: string) => Effect.Effect<ImpersonationGrantRecord | null>;
679
+ /** Close a grant. Only an OPEN grant is closed (`endedAt IS NULL`), so a
680
+ * double stop is a no-op rather than a rewritten end time. */
681
+ readonly endImpersonationGrant: (sessionId: string, endedReason: string, now: Date) => Effect.Effect<boolean>;
411
682
  /** Replace the user's recovery codes with a fresh (already hashed) set.
412
683
  * Called at enrolment / regeneration — wipes any prior codes so the
413
684
  * displayed set is the only valid one. */
package/dist/plugin.js CHANGED
@@ -1,4 +1,5 @@
1
+ import "./password.js";
1
2
  import "./session.js";
2
- import { n as e, t } from "./plugin-ZXs8LFn0.js";
3
+ import { n as e, t } from "./plugin-BZm5Semm.js";
3
4
  import "./csrf.js";
4
5
  export { t as authRoutesPlugin, e as getAuthSessionStrategy };
package/dist/schema.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { TableLike } from '@voltro/database';
2
2
 
3
3
  /** Tables the plugin contributes, as an array. */
4
- export declare const authTables: readonly [TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike];
4
+ export declare const authTables: readonly [TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike];
5
5
 
6
6
  /**
7
7
  * Auth-token table. Single-use, hashed, expiring tokens for the
@@ -12,6 +12,51 @@ export declare const authTables: readonly [TableLike, TableLike, TableLike, Tabl
12
12
  */
13
13
  export declare const authTokensTable: TableLike;
14
14
 
15
+ /**
16
+ * Impersonation grants — one row per "support agent A is acting as user B",
17
+ * written when the impersonated session is minted and closed when it ends.
18
+ *
19
+ * This table is the reason impersonation is auditable AT ALL without the app
20
+ * wiring anything: the session it describes is, by design, indistinguishable
21
+ * from B's own session everywhere downstream (same subject id, same tenant,
22
+ * same authority), so the fact that A is behind it exists in exactly two
23
+ * places — the session's `metadata.impersonation` mark, and this row. The mark
24
+ * can be dropped by an audit redaction policy; the row cannot.
25
+ *
26
+ * `sessionId` is UNIQUE and points at the `sessions` row for the impersonated
27
+ * session, which is what makes ending a grant a real revocation rather than a
28
+ * bookkeeping update: the stop path deletes that session row, and the
29
+ * request-time revocation check kills the cookie with it.
30
+ *
31
+ * `actorSessionId` is the IMPERSONATOR's own session, kept alive throughout so
32
+ * stopping restores them to their own identity instead of signing them out.
33
+ */
34
+ export declare const impersonationGrantsTable: TableLike;
35
+
36
+ /**
37
+ * Tenant invitations — one row per "you have been invited to <tenant> as
38
+ * <role>". The row IS the grant: accepting it writes the membership.
39
+ *
40
+ * Shaped like `authTokens` on purpose (hashed single-use token, `expiresAt`,
41
+ * a consumed stamp) because it has the same threat model — the plaintext only
42
+ * ever travels in the email, so a database leak yields no usable invitation.
43
+ * What it adds over a token row is the thing an invitation IS and a token is
44
+ * not: WHO it was addressed to, WHICH tenant it opens, and — the security
45
+ * property that decides the whole feature — WHAT ROLE it grants.
46
+ *
47
+ * `role` is written by the INVITER at creation and read at acceptance. The
48
+ * accept endpoint takes a token and nothing else, so an invitee cannot name
49
+ * their own role; the only way to change a pending invitation's role is to
50
+ * revoke it and issue another.
51
+ *
52
+ * Terminal states are all stamps rather than a `status` string, so a row can
53
+ * never claim two of them: `acceptedAt` (redeemed), `revokedAt` (withdrawn),
54
+ * and past `expiresAt` (lapsed). `acceptInvitation` matches on all three
55
+ * being clear in ONE conditional UPDATE, which is what makes acceptance
56
+ * single-use under concurrency rather than by convention.
57
+ */
58
+ export declare const invitationsTable: TableLike;
59
+
15
60
  /**
16
61
  * Login-attempt counters for brute-force lockout, keyed by normalized
17
62
  * email — NOT by user id. Tracking the EMAIL (whether or not a user row
package/dist/schema.js CHANGED
@@ -7,6 +7,7 @@ var a = n("users", {
7
7
  tenantId: r(),
8
8
  mfaSecret: r().nullable(),
9
9
  mfaEnrolledAt: i().nullable(),
10
+ emailVerifiedAt: i().nullable(),
10
11
  createdAt: i().default("now"),
11
12
  updatedAt: i().default("now").onUpdate("now")
12
13
  }).index(["tenantId"]), o = n("sessions", {
@@ -61,7 +62,34 @@ var a = n("users", {
61
62
  lockedUntil: i().nullable(),
62
63
  createdAt: i().default("now"),
63
64
  updatedAt: i().default("now").onUpdate("now")
64
- }), p = [
65
+ }), p = n("invitations", {
66
+ id: e({ prefix: "inv" }),
67
+ tokenHash: r().unique(),
68
+ email: r(),
69
+ tenantId: r(),
70
+ role: r(),
71
+ invitedByUserId: r(),
72
+ expiresAt: i(),
73
+ acceptedAt: i().nullable(),
74
+ acceptedByUserId: r().nullable(),
75
+ revokedAt: i().nullable(),
76
+ revokedByUserId: r().nullable(),
77
+ createdAt: i().default("now")
78
+ }).index(["tenantId"]).index(["email"]), m = n("impersonationGrants", {
79
+ id: e({ prefix: "imp" }),
80
+ sessionId: r().unique(),
81
+ actorUserId: r(),
82
+ actorSessionId: r(),
83
+ targetUserId: r(),
84
+ tenantId: r(),
85
+ reason: r().nullable(),
86
+ startedAt: i().default("now"),
87
+ expiresAt: i(),
88
+ endedAt: i().nullable(),
89
+ endedReason: r().nullable(),
90
+ ipAddress: r().nullable(),
91
+ userAgent: r().nullable()
92
+ }).index(["actorUserId"]).index(["targetUserId"]), h = [
65
93
  a,
66
94
  o,
67
95
  s,
@@ -69,7 +97,9 @@ var a = n("users", {
69
97
  l,
70
98
  u,
71
99
  d,
72
- f
100
+ f,
101
+ p,
102
+ m
73
103
  ];
74
104
  //#endregion
75
- export { p as authTables, c as authTokensTable, f as loginAttemptsTable, s as membershipsTable, d as passkeyChallengesTable, l as passkeysTable, u as recoveryCodesTable, o as sessionsTable, a as usersTable };
105
+ export { h as authTables, c as authTokensTable, m as impersonationGrantsTable, p as invitationsTable, f as loginAttemptsTable, s as membershipsTable, d as passkeyChallengesTable, l as passkeysTable, u as recoveryCodesTable, o as sessionsTable, a as usersTable };
package/dist/session.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { KeyedSecret } from '@voltro/protocol/session';
2
2
  import { SessionSecrets } from '@voltro/protocol/session';
3
3
  import { Subject } from '@voltro/protocol';
4
+ import { SubjectIdentity } from '@voltro/protocol';
4
5
  import { VerifyOptions } from '@voltro/protocol/session';
5
6
  import { VerifyResult } from '@voltro/protocol/session';
6
7
 
@@ -22,8 +23,14 @@ export declare interface IssuedSession {
22
23
  * header in one call. Apps just attach the header to their
23
24
  * response. Pass a `SessionSecrets` set to sign with the current
24
25
  * rotation key (its `kid` is stamped into the payload).
26
+ *
27
+ * The subject is a `Subject | SubjectIdentity` at the type level and an
28
+ * IDENTITY on the wire: a session cookie carries no `scopes`, and
29
+ * `signSession` throws rather than dropping them silently. Authority for
30
+ * this session is resolved per request by `auth.resolveScopes`, so a role
31
+ * removed after sign-in takes effect on this same session.
25
32
  */
26
- export declare const issueSession: (subject: Subject, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
33
+ export declare const issueSession: (subject: Subject | SubjectIdentity, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
27
34
 
28
35
  export declare interface IssueSessionOptions {
29
36
  readonly ttlSeconds?: number;
@@ -35,15 +42,16 @@ export { KeyedSecret }
35
42
 
36
43
  /**
37
44
  * Read + verify the session cookie from a Cookie header string.
38
- * Returns the decoded Subject or null when the cookie is missing,
39
- * malformed, tampered, or expired. Never throws — callers branch
40
- * on the boolean.
45
+ * Returns the decoded IDENTITY (a Subject with no `scopes` — authority is
46
+ * resolved per request, never carried in the cookie) or null when the cookie
47
+ * is missing, malformed, tampered, expired, or minted under a superseded
48
+ * payload version. Never throws — callers branch on the boolean.
41
49
  *
42
50
  * Verification is keyed: the given secret is widened via
43
51
  * `sessionSecretsOf`, so a cookie signed with the previous rotation
44
52
  * key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set.
45
53
  */
46
- export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => Subject | null;
54
+ export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => SubjectIdentity | null;
47
55
 
48
56
  /**
49
57
  * Keyed read: the full `VerifyResult` — subject + the `kid` that