@voltro/plugin-auth 0.1.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.
@@ -0,0 +1,351 @@
1
+ import { AuthStrategy } from '@voltro/protocol';
2
+ import { Effect } from 'effect';
3
+ import { SessionSecrets } from '@voltro/protocol/session';
4
+ import { Subject } from '@voltro/protocol';
5
+ import { VoidIfEmpty } from 'effect/Types';
6
+ import { VoltroPlugin } from '@voltro/protocol';
7
+ import { YieldableError } from 'effect/Cause';
8
+
9
+ declare interface AuthConfig {
10
+ readonly secret: string;
11
+ /** Explicit keyed secret set for zero-downtime rotation. When absent,
12
+ * the set is derived from `secret` (current) + the
13
+ * `VOLTRO_SESSION_SECRET_PREVIOUS` / `VOLTRO_SESSION_KID*` env vars —
14
+ * so env-var rotation works without setting this. Cookies are always
15
+ * SIGNED with the current key; verification also accepts `previous`. */
16
+ readonly secrets?: SessionSecrets;
17
+ readonly defaultTenantId: string;
18
+ readonly cookieDomain?: string;
19
+ readonly cookieSecure?: boolean;
20
+ /** Where to redirect on successful sign-in / sign-up. Default: '/'. */
21
+ readonly successRedirect?: string;
22
+ /** Injected email transport for magic-link + password-reset. When
23
+ * absent, those handlers still mint + persist the token but cannot
24
+ * deliver it (they return 202 so existence isn't leaked). The
25
+ * documented default forwards to `@voltro/plugin-mail` via
26
+ * `mailSender(mailService)`. */
27
+ readonly sendEmail?: SendEmail;
28
+ /** Absolute base URL used to build the links inside emails, e.g.
29
+ * `https://app.example.com`. Defaults to '' (relative links). */
30
+ readonly appBaseUrl?: string;
31
+ /** Post-authentication subject guards. After a login path resolves the
32
+ * authenticated user (credentials verified) but BEFORE a session is
33
+ * issued, every guard runs against that `UserRecord`; the first to veto
34
+ * (`{ ok: false }`) aborts the login with a 403 carrying its `code` and
35
+ * NO session cookie. General-purpose: `@voltro/plugin-deactivation`'s
36
+ * `deactivationGuard()` is the canonical one ("account deactivated"),
37
+ * but any concern (unverified email, suspended tenant, …) can hook here.
38
+ * Absent / empty ⇒ every authenticated user proceeds unchanged. */
39
+ readonly subjectGuards?: ReadonlyArray<SubjectGuard>;
40
+ }
41
+
42
+ /**
43
+ * The auth plugin instance also carries a pre-wired session
44
+ * `AuthStrategy` (keyed verify + the SAME revocation cache the HTTP
45
+ * routes use) so the framework's serve pipeline can slot it into the
46
+ * per-app auth chain — the rpc/WS path then rejects revoked sessions
47
+ * exactly like the `/auth/*` routes. Read it via
48
+ * `getAuthSessionStrategy`.
49
+ */
50
+ export declare interface AuthPlugin extends VoltroPlugin {
51
+ readonly auth: {
52
+ readonly sessionStrategy: AuthStrategy;
53
+ };
54
+ }
55
+
56
+ export declare const authRoutesPlugin: (options: AuthRoutesPluginOptions) => AuthPlugin;
57
+
58
+ export declare interface AuthRoutesPluginOptions extends AuthConfig {
59
+ /** The user store backing every handler. */
60
+ readonly store: UserStore;
61
+ /** Passkey config — when omitted, the four passkey routes return 501. */
62
+ readonly passkey?: PasskeyConfig;
63
+ /** Challenge store for the passkey ceremonies. Defaults to an in-memory
64
+ * single-node store. */
65
+ readonly challengeStore?: ChallengeStore;
66
+ /** MFA (TOTP) enrolment config. When set, the `/auth/mfa/enroll/*`
67
+ * routes are mounted so an authenticated user can enrol a second
68
+ * factor; `issuer` is the label shown in their authenticator app.
69
+ * Sign-in enforcement (the `/auth/mfa/verify` challenge) is ALWAYS
70
+ * active for enrolled users regardless of this — it only gates the
71
+ * enrolment routes. */
72
+ readonly mfa?: MfaConfig;
73
+ /** Rebind hook for switch-tenant on the live WS path — pass
74
+ * `bindConnectionSubject` from `@voltro/runtime`. */
75
+ readonly rebind?: RebindConnection;
76
+ /** Route prefix. Default `/auth`. */
77
+ readonly prefix?: string;
78
+ /** Tuning for the request-time session-revocation check (TTL cache
79
+ * window, default 30s; `now` is a testing seam). */
80
+ readonly sessionRevocation?: SessionRevocationOptions;
81
+ /** Disambiguates multiple instances of this plugin in one app. */
82
+ readonly name?: string;
83
+ }
84
+
85
+ /** Single-use challenge storage. `key` is `<userId>:<ceremony>`. */
86
+ declare interface ChallengeStore {
87
+ readonly put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect<void>;
88
+ /** Read AND delete (single-use). Returns null when absent/expired. */
89
+ readonly take: (key: string) => Effect.Effect<string | null>;
90
+ }
91
+
92
+ /**
93
+ * Pull the pre-wired session strategy off a plugin list (the framework's
94
+ * serve pipeline calls this when composing the auth chain). Returns the
95
+ * FIRST auth plugin's strategy, or null when no auth plugin is present.
96
+ */
97
+ export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
98
+
99
+ /** One tenant a user belongs to. The active tenant lives on the Subject;
100
+ * the full set drives the switch-tenant menu + the switch-tenant guard. */
101
+ declare interface MembershipRecord {
102
+ readonly userId: string;
103
+ readonly tenantId: string;
104
+ readonly role: string;
105
+ readonly joinedAt: Date;
106
+ }
107
+
108
+ /** MFA (TOTP) enrolment config. */
109
+ export declare interface MfaConfig {
110
+ /** Issuer label shown in the user's authenticator app (typically your
111
+ * product name, e.g. "Voltro Cloud"). */
112
+ readonly issuer: string;
113
+ }
114
+
115
+ declare interface PasskeyConfig {
116
+ /** Relying-party id — the registrable domain (e.g. `example.com`). */
117
+ readonly rpId: string;
118
+ /** Human-readable RP name shown in the OS prompt. */
119
+ readonly rpName: string;
120
+ /** The exact origin ceremonies must run on (e.g. `https://app.example.com`). */
121
+ readonly origin: string;
122
+ }
123
+
124
+ /** A registered WebAuthn credential. */
125
+ declare interface PasskeyRecord {
126
+ readonly id: string;
127
+ readonly credentialId: string;
128
+ readonly userId: string;
129
+ readonly publicKey: string;
130
+ readonly counter: number;
131
+ readonly transports?: string | null;
132
+ readonly createdAt: Date;
133
+ readonly lastUsedAt: Date | null;
134
+ }
135
+
136
+ /** Injected rebinder — the app passes `bindConnectionSubject` from
137
+ * `@voltro/runtime` so the plugin stays runtime-agnostic. */
138
+ declare type RebindConnection = (clientId: number, subject: ReturnType<typeof subjectFromUser>) => void;
139
+
140
+ declare type SendEmail = (input: SendEmailInput) => Promise<void>;
141
+
142
+ /** The email hook the plugin calls for magic-link + password-reset. Inject
143
+ * it on the config. The documented default wiring forwards to
144
+ * `@voltro/plugin-mail`'s `MailService.send` (see `mailSender`). */
145
+ declare interface SendEmailInput {
146
+ readonly to: string;
147
+ readonly subject: string;
148
+ readonly html: string;
149
+ readonly text: string;
150
+ /** Discriminates the flow so a custom sender can branch. */
151
+ readonly kind: 'magic-link' | 'password-reset';
152
+ /** The action URL embedded in the email (also present inside `html`). */
153
+ readonly actionUrl: string;
154
+ }
155
+
156
+ /** A server-side session row — enumerated so apps can list active
157
+ * devices + revoke them. Written on sign-in; deleted on revoke. */
158
+ declare interface SessionRecord {
159
+ readonly id: string;
160
+ readonly userId: string;
161
+ readonly tenantId: string;
162
+ readonly expiresAt: Date;
163
+ readonly ipAddress?: string | null;
164
+ readonly userAgent?: string | null;
165
+ readonly createdAt: Date;
166
+ readonly lastSeenAt: Date;
167
+ }
168
+
169
+ declare interface SessionRevocationOptions {
170
+ /** Cache window in milliseconds. A revocation performed elsewhere
171
+ * takes effect on this process within this window. Default 30s.
172
+ * `0` disables caching (every verify hits the store). */
173
+ readonly ttlMs?: number;
174
+ /** Upper bound on cached session ids. When exceeded, expired entries
175
+ * are swept; if still over, the oldest entries are dropped. Default
176
+ * 10 000. */
177
+ readonly maxEntries?: number;
178
+ /** Clock override (epoch milliseconds). Defaults to `Date.now`. A
179
+ * testing seam — lets suites cross the cache window without
180
+ * wall-clock sleeps. */
181
+ readonly now?: () => number;
182
+ }
183
+
184
+ /**
185
+ * Convert a UserRecord into the protocol Subject the framework
186
+ * carries around per-request. Tenant id flows through; user type
187
+ * is locked to 'user'.
188
+ *
189
+ * The user's tenant memberships, when supplied, are carried in
190
+ * `metadata.memberships` (the protocol Subject's metadata slot — the
191
+ * framework never reads it, but app code + the switch-tenant menu do).
192
+ * Pass an explicit `tenantId` to make the ACTIVE tenant differ from the
193
+ * user's home tenant (post switch-tenant rebind).
194
+ */
195
+ declare const subjectFromUser: (user: UserRecord, options?: {
196
+ readonly tenantId?: string;
197
+ readonly memberships?: ReadonlyArray<MembershipRecord>;
198
+ }) => Subject;
199
+
200
+ /**
201
+ * A post-authentication subject guard: given the authenticated user,
202
+ * decide whether the login may proceed. Runs after the credential check,
203
+ * before the session is issued. Effect-native so a guard can do IO (e.g.
204
+ * read a fresh flag) without a Promise bridge.
205
+ */
206
+ declare type SubjectGuard = (user: UserRecord) => Effect.Effect<SubjectGuardVerdict>;
207
+
208
+ /** A guard's decision. Allow, or reject with a stable machine `code`
209
+ * (surfaced as the 403 body's `error`) plus a human `message`. */
210
+ declare type SubjectGuardVerdict = {
211
+ readonly ok: true;
212
+ } | {
213
+ readonly ok: false;
214
+ readonly code: string;
215
+ readonly message: string;
216
+ };
217
+
218
+ declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
219
+
220
+ /** A single-use, hashed, expiring token (magic-link / password-reset). */
221
+ declare interface TokenRecord {
222
+ readonly id: string;
223
+ readonly tokenHash: string;
224
+ readonly userId: string;
225
+ readonly purpose: TokenPurpose;
226
+ readonly expiresAt: Date;
227
+ readonly consumedAt: Date | null;
228
+ readonly createdAt: Date;
229
+ }
230
+
231
+ declare class UserAlreadyExistsError extends UserAlreadyExistsError_base<{
232
+ readonly email: string;
233
+ }> {
234
+ }
235
+
236
+ declare const UserAlreadyExistsError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
237
+ readonly _tag: "UserAlreadyExistsError";
238
+ } & Readonly<A>;
239
+
240
+ declare class UserNotFoundError extends UserNotFoundError_base<{
241
+ readonly userId: string;
242
+ }> {
243
+ }
244
+
245
+ declare const UserNotFoundError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
246
+ readonly _tag: "UserNotFoundError";
247
+ } & Readonly<A>;
248
+
249
+ declare interface UserRecord {
250
+ readonly id: string;
251
+ readonly email: string;
252
+ readonly passwordHash: string;
253
+ readonly tenantId: string;
254
+ readonly createdAt: Date;
255
+ /** Base32-encoded TOTP secret. `null` = MFA not enrolled. Set by
256
+ * `setMfaSecret()` from the enrolment handler. Stored as plaintext. */
257
+ readonly mfaSecret?: string | null;
258
+ /** Timestamp the user completed TOTP enrolment (first successful
259
+ * verify). Distinct from the secret being set — a half-completed
260
+ * enrolment leaves `mfaSecret` populated but this null. */
261
+ readonly mfaEnrolledAt?: Date | null;
262
+ /** Optional lifecycle flag read by post-authentication subject guards.
263
+ * Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
264
+ * deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
265
+ * `null` / absent means active. A store populates it from the app's
266
+ * `deactivatedAt` column (added by the `deactivation()` mixin) when that
267
+ * column exists; stores without the column simply leave it undefined and
268
+ * the guard treats the account as active. */
269
+ readonly deactivatedAt?: Date | null;
270
+ }
271
+
272
+ declare interface UserStore {
273
+ readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
274
+ readonly findById: (id: string) => Effect.Effect<UserRecord | null>;
275
+ readonly insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>;
276
+ /** Replace a user's stored password hash. Used by password-reset AND
277
+ * by rehash-on-verify (silently upgrading an under-cost hash on a
278
+ * successful sign-in). */
279
+ readonly updatePassword: (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>;
280
+ /** Stash a freshly-generated TOTP secret on the user. Called by
281
+ * the MFA enrolment handler before the user verifies the first
282
+ * code. */
283
+ readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>;
284
+ /** Mark MFA as fully enrolled (called after the first successful
285
+ * TOTP verify completes the enrolment ceremony). */
286
+ readonly markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
287
+ /** Wipe the secret + enrolledAt. Used by /account/security's
288
+ * "remove MFA" action. */
289
+ readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
290
+ /** Every tenant the user belongs to. */
291
+ readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
292
+ /** Idempotent grant: inserts the [userId, tenantId] membership or
293
+ * updates its role if it already exists. The DB-level composite
294
+ * UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the
295
+ * source of truth; this upsert converges to it. */
296
+ readonly addMembership: (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>;
297
+ /** Resolve the role for a [userId, tenantId] pair, or null when the
298
+ * user is NOT a member. The switch-tenant handler calls this to
299
+ * validate the requested tenant before rebinding the connection. */
300
+ readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect<string | null>;
301
+ /** Active sessions for a user (most recent first). */
302
+ readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
303
+ /** Record a session on sign-in. */
304
+ readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
305
+ /** Look up one session row by id. The request-time revocation check
306
+ * (`makeSessionRevocationChecker`) calls this — a missing row means
307
+ * the session was revoked. */
308
+ readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
309
+ /** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
310
+ * when the sliding-window renewal re-issues the cookie, so the row
311
+ * outlives the renewed cookie and the revocation check keeps passing. */
312
+ readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect<void>;
313
+ /** Revoke a single session — only if it belongs to `userId` (so a
314
+ * caller can't revoke another user's session by id-guessing). Returns
315
+ * whether a row was removed. */
316
+ readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect<boolean>;
317
+ /** Revoke every session for the user EXCEPT `keepSessionId` — the
318
+ * "sign out other devices" action. Returns the count removed. */
319
+ readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
320
+ /** Persist a single-use token (already hashed). */
321
+ readonly insertToken: (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>;
322
+ /** Atomically redeem a token by its hash + purpose: returns the row
323
+ * only when it exists, matches the purpose, is unexpired, and was not
324
+ * already consumed — and marks it consumed in the same step. Returns
325
+ * null otherwise (single-use guard). */
326
+ readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
327
+ /** Replace the user's recovery codes with a fresh (already hashed) set.
328
+ * Called at enrolment / regeneration — wipes any prior codes so the
329
+ * displayed set is the only valid one. */
330
+ readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>;
331
+ /** Atomically redeem one recovery code by its hash: returns true only
332
+ * when an unconsumed row for `userId` matched — and marks it consumed
333
+ * in the same step (single-use). false otherwise. */
334
+ readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect<boolean>;
335
+ /** Count the user's remaining (unconsumed) recovery codes — surfaced so
336
+ * the UI can warn when the user is running low. */
337
+ readonly countRecoveryCodes: (userId: string) => Effect.Effect<number>;
338
+ readonly listPasskeys: (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>;
339
+ readonly findPasskey: (credentialId: string) => Effect.Effect<PasskeyRecord | null>;
340
+ readonly insertPasskey: (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>;
341
+ /** Atomically bump the stored signature counter, guarding against a
342
+ * cloned authenticator (WebAuthn §6.1.1): the update only applies when
343
+ * `newCounter` strictly exceeds the stored counter. Returns whether a
344
+ * row was advanced. `false` with an unchanged stored counter is the
345
+ * clone/replay signal the assertion handler rejects on — the check and
346
+ * the write are ONE atomic statement at the store, so two replicas
347
+ * racing the same counter can't both succeed. */
348
+ readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>;
349
+ }
350
+
351
+ export { }
package/dist/plugin.js ADDED
@@ -0,0 +1,4 @@
1
+ import "./session.js";
2
+ import { n as e, t } from "./plugin-CRt-kdEA.js";
3
+ import "./csrf.js";
4
+ export { t as authRoutesPlugin, e as getAuthSessionStrategy };
@@ -0,0 +1,72 @@
1
+ import { TableLike } from '@voltro/database';
2
+
3
+ /** Tables the plugin contributes, as an array. */
4
+ export declare const authTables: readonly [TableLike, TableLike, TableLike, TableLike, TableLike, TableLike, TableLike];
5
+
6
+ /**
7
+ * Auth-token table. Single-use, hashed, expiring tokens for the
8
+ * magic-link sign-in + password-reset flows. We store only the SHA-256
9
+ * of the token (never the plaintext) so a DB leak can't be replayed.
10
+ * `purpose` discriminates the flow; `consumedAt` is set the first time
11
+ * a token is redeemed (single-use guard).
12
+ */
13
+ export declare const authTokensTable: TableLike;
14
+
15
+ /**
16
+ * Memberships table. A user belongs to MANY tenants; each row is one
17
+ * membership with a `role`. The active tenant lives on the Subject, but
18
+ * the set of tenants a user MAY switch into is enumerated here.
19
+ *
20
+ * `[userId, tenantId]` is a composite UNIQUE — the DB enforces the
21
+ * one-membership-per-(user, tenant) invariant that `addMembership`
22
+ * upserts toward, and the unique constraint doubles as the BTREE index
23
+ * backing the membership lookup + the dedup probe (so no separate
24
+ * `.index([...])` on the same columns is needed).
25
+ */
26
+ export declare const membershipsTable: TableLike;
27
+
28
+ /**
29
+ * Passkey ceremony challenges — the DataStore-backed `ChallengeStore`
30
+ * (`dataStoreChallengeStore`) writes here so a multi-replica deployment's
31
+ * register/assert ceremonies survive when `options` and `verify` land on
32
+ * different replicas (the in-memory store is single-node only). Rows are
33
+ * single-use + short-lived: `take` deletes on read, and `expiresAt` bounds
34
+ * a challenge abandoned before verify. `key` is `<userId>:<ceremony>`.
35
+ */
36
+ export declare const passkeyChallengesTable: TableLike;
37
+
38
+ /**
39
+ * Passkey (WebAuthn) credentials. One row per registered authenticator.
40
+ * `publicKey` is the COSE public key (base64url). `counter` is the
41
+ * authenticator's signature counter — the assertion handler rejects any
42
+ * assertion whose counter does not strictly exceed the stored value
43
+ * (cloned-authenticator detection).
44
+ */
45
+ export declare const passkeysTable: TableLike;
46
+
47
+ /**
48
+ * MFA recovery (backup) codes. One row per code, stored HASHED (SHA-256,
49
+ * same as auth tokens — never the plaintext). Minted at enrolment, shown
50
+ * once, single-use: `consumedAt` is stamped the first time a code is
51
+ * redeemed at sign-in as the authenticator-loss fallback.
52
+ */
53
+ export declare const recoveryCodesTable: TableLike;
54
+
55
+ /**
56
+ * Sessions table. Active sessions enumerated server-side so apps can
57
+ * implement "sign out other devices" + server-side revocation.
58
+ * `handleSignIn` / `handleSignOut` do NOT write here (cookies are
59
+ * self-describing HMAC-signed); apps that need device management
60
+ * wire that up themselves.
61
+ */
62
+ export declare const sessionsTable: TableLike;
63
+
64
+ /**
65
+ * Users table. `tenantId` is a plain text column (not a reference)
66
+ * so the table can be added to apps that haven't yet declared their
67
+ * own `tenants` table. Apps with a tenants store can layer a
68
+ * referential constraint on top via the DB schema or DSL extensions.
69
+ */
70
+ export declare const usersTable: TableLike;
71
+
72
+ export { }
package/dist/schema.js ADDED
@@ -0,0 +1,66 @@
1
+ import { id as e, integer as t, table as n, text as r, timestamp as i } from "@voltro/database";
2
+ //#region src/schema/index.ts
3
+ var a = n("users", {
4
+ id: e(),
5
+ email: r().unique(),
6
+ passwordHash: r(),
7
+ tenantId: r(),
8
+ mfaSecret: r().nullable(),
9
+ mfaEnrolledAt: i().nullable(),
10
+ createdAt: i().default("now"),
11
+ updatedAt: i().default("now").onUpdate("now")
12
+ }).index(["tenantId"]), o = n("sessions", {
13
+ id: e(),
14
+ userId: r(),
15
+ tenantId: r(),
16
+ expiresAt: i(),
17
+ ipAddress: r().nullable(),
18
+ userAgent: r().nullable(),
19
+ createdAt: i().default("now"),
20
+ lastSeenAt: i().default("now").onUpdate("now")
21
+ }).index(["userId"]).index(["tenantId"]), s = n("memberships", {
22
+ id: e(),
23
+ userId: r(),
24
+ tenantId: r(),
25
+ role: r(),
26
+ joinedAt: i().default("now")
27
+ }).unique("byUserTenant", ["userId", "tenantId"]).index(["tenantId"]), c = n("authTokens", {
28
+ id: e(),
29
+ tokenHash: r().unique(),
30
+ userId: r(),
31
+ purpose: r(),
32
+ expiresAt: i(),
33
+ consumedAt: i().nullable(),
34
+ createdAt: i().default("now")
35
+ }).index(["userId"]).index(["purpose"]), l = n("passkeys", {
36
+ id: e(),
37
+ credentialId: r().unique(),
38
+ userId: r(),
39
+ publicKey: r(),
40
+ counter: t().default(0),
41
+ transports: r().nullable(),
42
+ createdAt: i().default("now"),
43
+ lastUsedAt: i().nullable()
44
+ }).index(["userId"]), u = n("recoveryCodes", {
45
+ id: e(),
46
+ userId: r(),
47
+ codeHash: r(),
48
+ consumedAt: i().nullable(),
49
+ createdAt: i().default("now")
50
+ }).index(["userId"]), d = n("passkeyChallenges", {
51
+ id: e(),
52
+ key: r().unique(),
53
+ challenge: r(),
54
+ expiresAt: i(),
55
+ createdAt: i().default("now")
56
+ }), f = [
57
+ a,
58
+ o,
59
+ s,
60
+ c,
61
+ l,
62
+ u,
63
+ d
64
+ ];
65
+ //#endregion
66
+ export { f as authTables, c as authTokensTable, s as membershipsTable, d as passkeyChallengesTable, l as passkeysTable, u as recoveryCodesTable, o as sessionsTable, a as usersTable };
@@ -0,0 +1,86 @@
1
+ import { KeyedSecret } from '@voltro/protocol/session';
2
+ import { SessionSecrets } from '@voltro/protocol/session';
3
+ import { Subject } from '@voltro/protocol';
4
+ import { VerifyOptions } from '@voltro/protocol/session';
5
+ import { VerifyResult } from '@voltro/protocol/session';
6
+
7
+ /**
8
+ * Build a `Set-Cookie` value that clears the session cookie. Send
9
+ * this on logout.
10
+ */
11
+ export declare const clearSessionCookie: (options?: IssueSessionOptions) => string;
12
+
13
+ export declare interface IssuedSession {
14
+ /** The opaque cookie value — `<payload>.<sig>`. */
15
+ readonly value: string;
16
+ /** Ready-to-Set-Cookie header string with sane defaults. */
17
+ readonly setCookie: string;
18
+ }
19
+
20
+ /**
21
+ * Mint a signed session AND build the corresponding `Set-Cookie`
22
+ * header in one call. Apps just attach the header to their
23
+ * response. Pass a `SessionSecrets` set to sign with the current
24
+ * rotation key (its `kid` is stamped into the payload).
25
+ */
26
+ export declare const issueSession: (subject: Subject, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
27
+
28
+ export declare interface IssueSessionOptions {
29
+ readonly ttlSeconds?: number;
30
+ readonly domain?: string;
31
+ readonly secure?: boolean;
32
+ }
33
+
34
+ export { KeyedSecret }
35
+
36
+ /**
37
+ * 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.
41
+ *
42
+ * Verification is keyed: the given secret is widened via
43
+ * `sessionSecretsOf`, so a cookie signed with the previous rotation
44
+ * key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set.
45
+ */
46
+ export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => Subject | null;
47
+
48
+ /**
49
+ * Keyed read: the full `VerifyResult` — subject + the `kid` that
50
+ * verified + the sliding-window `renew` flag + `exp`/`iat`. The auth
51
+ * routes plugin re-issues the cookie when `renew` is set or when the
52
+ * value verified under the previous key.
53
+ */
54
+ export declare const readSessionKeyed: (cookieHeader: string | undefined, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null;
55
+
56
+ export declare const resolveSessionSecret: () => string;
57
+
58
+ export declare const resolveSessionSecrets: () => SessionSecrets;
59
+
60
+ export declare const SESSION_COOKIE_NAME: "voltro:session";
61
+
62
+ /** Every session helper takes either a bare secret string (single-key)
63
+ * or the keyed `{ current, previous? }` rotation set. */
64
+ export declare type SessionSecretInput = string | SessionSecrets;
65
+
66
+ export { SessionSecrets }
67
+
68
+ /**
69
+ * Normalise a secret input into the keyed set VERIFICATION runs against.
70
+ *
71
+ * - A `SessionSecrets` set passes through unchanged.
72
+ * - A bare string becomes `current` and is widened with the env-driven
73
+ * `previous` key (`VOLTRO_SESSION_SECRET_PREVIOUS` +
74
+ * `VOLTRO_SESSION_KID_PREVIOUS`) so env-var rotation works without
75
+ * the app switching its config to the keyed shape. When the string
76
+ * IS the env secret (`VOLTRO_SESSION_SECRET`), it inherits the env
77
+ * `kid`; otherwise the default kid applies. The comparison is
78
+ * constant-time — secrets never go through `===`.
79
+ */
80
+ export declare const sessionSecretsOf: (secret: SessionSecretInput) => SessionSecrets;
81
+
82
+ export { VerifyResult }
83
+
84
+ export declare const VOLTRO_DEV_SESSION_SECRET: "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD";
85
+
86
+ export { }
@@ -0,0 +1,38 @@
1
+ import { DEFAULT_SESSION_KID as e, VOLTRO_DEV_SESSION_SECRET as t, buildSetCookie as n, readCookie as r, resolveSessionSecret as i, resolveSessionSecrets as a, signSession as o, timingSafeStringEqual as s, verifySessionKeyed as c } from "@voltro/protocol/session";
2
+ //#region src/session.ts
3
+ var l = "voltro:session", u = 3600 * 24 * 7, d = t, f = i, p = a, m = (e) => typeof e == "string" ? e : e.current, h = (t) => {
4
+ if (typeof t != "string") return t;
5
+ let n = a(), r = {
6
+ kid: s(n.current.secret, t) ? n.current.kid : e,
7
+ secret: t
8
+ };
9
+ return n.previous ? {
10
+ current: r,
11
+ previous: n.previous
12
+ } : { current: r };
13
+ }, g = (e, t, r = {}) => {
14
+ let i = r.ttlSeconds ?? u, a = o(e, m(t), { ttlSeconds: i });
15
+ return {
16
+ value: a,
17
+ setCookie: n(l, a, {
18
+ maxAgeSeconds: i,
19
+ httpOnly: !0,
20
+ sameSite: "lax",
21
+ secure: r.secure ?? !0,
22
+ ...r.domain ? { domain: r.domain } : {},
23
+ path: "/"
24
+ })
25
+ };
26
+ }, _ = (e, t) => v(e, t)?.subject ?? null, v = (e, t, n = {}) => {
27
+ let i = r(e, l);
28
+ return i ? c(i, h(t), n) : null;
29
+ }, y = (e = {}) => n(l, "", {
30
+ maxAgeSeconds: 0,
31
+ httpOnly: !0,
32
+ sameSite: "lax",
33
+ secure: e.secure ?? !0,
34
+ ...e.domain ? { domain: e.domain } : {},
35
+ path: "/"
36
+ });
37
+ //#endregion
38
+ export { l as SESSION_COOKIE_NAME, d as VOLTRO_DEV_SESSION_SECRET, y as clearSessionCookie, g as issueSession, _ as readSession, v as readSessionKeyed, f as resolveSessionSecret, p as resolveSessionSecrets, h as sessionSecretsOf };