@voltro/plugin-auth 0.32.0 → 0.34.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/CHANGELOG.md +2006 -0
- package/dist/index.d.ts +830 -26
- package/dist/index.js +182 -52
- package/dist/password.d.ts +2 -1
- package/dist/password.js +44 -35
- package/dist/plugin-BZm5Semm.js +1330 -0
- package/dist/plugin.d.ts +315 -44
- package/dist/plugin.js +2 -1
- package/dist/schema.d.ts +46 -1
- package/dist/schema.js +33 -3
- package/dist/session.d.ts +13 -5
- package/dist/{strategy-Ce4FlGi4.js → strategy-Bty6_XUj.js} +84 -12
- package/dist/strategy.d.ts +111 -1
- package/dist/strategy.js +1 -1
- package/dist/tokens.d.ts +36 -1
- package/dist/tokens.js +15 -8
- package/package.json +5 -4
- package/dist/plugin-ZXs8LFn0.js +0 -820
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
|
-
* `
|
|
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
|
-
/**
|
|
183
|
-
*
|
|
184
|
-
|
|
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
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 {
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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) =>
|
|
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
|