@voltro/plugin-auth 0.80.0 → 0.82.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/index.d.ts CHANGED
@@ -25,6 +25,37 @@ export declare interface AcceptInvitationWithSignUpInput {
25
25
  readonly password: string;
26
26
  }
27
27
 
28
+ /** What the decision can read: the app's database the plugin is bound to
29
+ * (undefined before the bind, and for a handler called without the plugin). */
30
+ export declare interface AdmissionContext {
31
+ readonly store: DataStore | undefined;
32
+ }
33
+
34
+ /** Why a join is refused. */
35
+ export declare interface AdmissionRefusal {
36
+ /** A stable code the client can branch on, e.g. `seats`. */
37
+ readonly reason: string;
38
+ /** A sentence for the person who was refused. */
39
+ readonly message: string;
40
+ }
41
+
42
+ /** Decide whether a person may join a tenant — `null` to let them in. */
43
+ export declare type AdmitJoining = (joining: Joining, context: AdmissionContext) => Effect.Effect<AdmissionRefusal | null> | Promise<AdmissionRefusal | null>;
44
+
45
+ /**
46
+ * Keep users in the app's own database.
47
+ *
48
+ * - SQL store on postgres → `postgresUserStore` over the framework's pool. The
49
+ * app registers `authTables` (from `@voltro/plugin-auth/schema`) in its
50
+ * `databaseHandle` so auto-migrate creates them.
51
+ * - `store: 'memory'` → the in-process store: nothing else in the app is
52
+ * durable either, so the two stay consistent.
53
+ * - Any other SQL dialect → the boot fails with the reason. There is no
54
+ * user store for it yet, and silently keeping users in memory beside a
55
+ * durable database would lose every account on restart.
56
+ */
57
+ export declare const appUserStore: (options?: PostgresUserStoreOptions) => UserStoreResolver;
58
+
28
59
  export declare type AssertionOutcome = {
29
60
  readonly ok: true;
30
61
  readonly result: AssertionResult;
@@ -95,6 +126,28 @@ export declare interface AuthConfig {
95
126
  * but any concern (unverified email, suspended tenant, …) can hook here.
96
127
  * Absent / empty ⇒ every authenticated user proceeds unchanged. */
97
128
  readonly subjectGuards?: ReadonlyArray<SubjectGuard>;
129
+ /** Admission: asked before anyone JOINS a tenant — a sign-up, an invitation
130
+ * accepted with a new account or an existing one — and before anything is
131
+ * written (the account, the membership, the redeemed invitation). A refusal
132
+ * answers 403 `not_admitted` with its `reason` and `message`. The seam for a
133
+ * seat limit, an invite-only tenant, an address allow-list. Absent ⇒ every
134
+ * join proceeds. Signing in is not joining: subject guards govern that. */
135
+ readonly admit?: AdmitJoining;
136
+ /**
137
+ * Tenants a public sign-up may name (`tenantId` in the sign-up body) besides
138
+ * `defaultTenantId` — a readers' tenant beside the staff one. Anything else
139
+ * named is refused (400 `tenant_not_open`) before an account exists: the
140
+ * request must not choose the tenant it joins, or anyone could sign up into
141
+ * a stranger's. Joining a particular organisation goes through an invitation.
142
+ */
143
+ readonly signUpTenants?: ReadonlyArray<string>;
144
+ /** Make sure the user has a row in the app's `actors` table before their
145
+ * session exists. The `audit()` mixin stamps `createdBy` / `updatedBy`
146
+ * with the user id, and those columns reference `actors`, so a user with no
147
+ * row there cannot write to any audited table on an FK-enforcing database.
148
+ * `authRoutesPlugin` wires this to the app store it is bound to; called by
149
+ * `issueUserSession`, so every session-granting path covers it. */
150
+ readonly ensureActor?: (user: UserRecord) => Effect.Effect<void>;
98
151
  /** Brute-force lockout. After `maxAttempts` failed credential attempts
99
152
  * (wrong password OR wrong MFA code) within `windowSeconds`, the account —
100
153
  * keyed by EMAIL, so an unknown address locks exactly like a real one and
@@ -141,8 +194,12 @@ export declare interface AuthPlugin extends VoltroPlugin {
141
194
  export declare const authRoutesPlugin: (options: AuthRoutesPluginOptions) => AuthPlugin;
142
195
 
143
196
  export declare interface AuthRoutesPluginOptions extends AuthConfig {
144
- /** The user store backing every handler. */
145
- readonly store: UserStore;
197
+ /**
198
+ * The user store backing every handler — a `UserStore`, or a
199
+ * `UserStoreResolver` (e.g. `appUserStore()`) that the plugin calls once the
200
+ * framework has opened the app's database, so users live in that database.
201
+ */
202
+ readonly store: UserStore | UserStoreResolver;
146
203
  /** Passkey config — when omitted, the four passkey routes return 501. */
147
204
  readonly passkey?: PasskeyConfig;
148
205
  /** Challenge store for the passkey ceremonies. Defaults to an in-memory
@@ -186,6 +243,13 @@ export declare interface AuthRoutesPluginOptions extends AuthConfig {
186
243
  * on while leaving either unanswered. See `ImpersonationConfig`.
187
244
  */
188
245
  readonly impersonation?: ImpersonationConfig;
246
+ /**
247
+ * Native login for cookie-less clients (React Native, desktop shells).
248
+ * PRESENT mounts `/auth/native/authorize`, `/native/complete` and
249
+ * `/native/exchange`; ABSENT mounts nothing. `redirectUris` is exact-match —
250
+ * an app names the custom-scheme or universal-link URL it listens on.
251
+ */
252
+ readonly native?: NativeLoginConfig;
189
253
  /** Disambiguates multiple instances of this plugin in one app. */
190
254
  readonly name?: string;
191
255
  }
@@ -240,6 +304,14 @@ export declare const CSRF_HEADER_NAME: "x-csrf-token";
240
304
  */
241
305
  export declare const dataStoreChallengeStore: (sql: SqlClient) => ChallengeStore;
242
306
 
307
+ /**
308
+ * Session rows in the app's own database — the `sessions` table from
309
+ * `@voltro/plugin-auth/schema`, which the app registers like any other. Reads
310
+ * and writes go through the store the framework opened, so there is no second
311
+ * pool and no second schema.
312
+ */
313
+ export declare const dataStoreSessionRegistry: (store: DataStore) => SessionRegistry;
314
+
243
315
  /** Default set of roles allowed to invite at all. */
244
316
  export declare const DEFAULT_INVITER_ROLES: ReadonlyArray<string>;
245
317
 
@@ -559,6 +631,33 @@ export declare const handleMfaUnenroll: (input: MfaUnenrollInput, store: Identit
559
631
  */
560
632
  export declare const handleMfaVerify: (input: MfaVerifyInput, store: IdentityStore & MfaStore & MembershipStore & SessionStore & TokenStore & LockoutStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
561
633
 
634
+ /**
635
+ * `GET /native/authorize`: validate the request, store it in the pending
636
+ * cookie, then either complete immediately (already signed in) or send the
637
+ * browser to the login page.
638
+ */
639
+ export declare const handleNativeAuthorize: (query: URLSearchParams, session: {
640
+ readonly subject: Subject;
641
+ readonly sessionId: string | null;
642
+ } | null, store: TokenStore, deps: NativeRouteDeps) => Effect.Effect<HandlerResult>;
643
+
644
+ /**
645
+ * `GET /native/complete`: the browser is back from the login page. Without a
646
+ * session it goes to the login page again; without a pending cookie there is
647
+ * nothing to complete.
648
+ */
649
+ export declare const handleNativeComplete: (cookieHeader: string | undefined, session: {
650
+ readonly subject: Subject;
651
+ readonly sessionId: string | null;
652
+ } | null, store: TokenStore, deps: NativeRouteDeps) => Effect.Effect<HandlerResult>;
653
+
654
+ /**
655
+ * `POST /native/exchange`: consume the one-time code, check the PKCE
656
+ * verifier, and hand back the session value for the row the browser login
657
+ * created. Every failure is the same 400 — the code is single-use either way.
658
+ */
659
+ export declare const handleNativeExchange: (input: NativeExchangeInput, store: TokenStore & IdentityStore & MembershipStore & SessionStore & LockoutStore, deps: NativeRouteDeps) => Effect.Effect<HandlerResult>;
660
+
562
661
  /** Step 3: mint assertion options for `navigator.credentials.get`. */
563
662
  export declare const handlePasskeyAssertOptions: (input: PasskeyAssertOptionsInput, store: PasskeyStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect<HandlerResult>;
564
663
 
@@ -998,6 +1097,8 @@ export declare interface InvitationDeps {
998
1097
  }) => Promise<void>;
999
1098
  readonly invitations?: InvitationsConfig;
1000
1099
  readonly now?: () => number;
1100
+ /** Asked before an invitation is redeemed — see `AuthConfig.admit`. */
1101
+ readonly admit?: AdmitJoining;
1001
1102
  }
1002
1103
 
1003
1104
  /**
@@ -1089,6 +1190,8 @@ export declare interface InvitationStore {
1089
1190
  readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
1090
1191
  }
1091
1192
 
1193
+ export declare const isAllowedRedirectUri: (uri: string, allowed: ReadonlyArray<string>) => boolean;
1194
+
1092
1195
  /** Verify a token's own HMAC (signature integrity), independent of the
1093
1196
  * cookie/header match. */
1094
1197
  export declare const isCsrfTokenWellFormed: (token: string, secret: string) => boolean;
@@ -1119,6 +1222,11 @@ export declare const isImpersonated: (subject: Subject) => boolean;
1119
1222
  */
1120
1223
  export declare const issueCsrfToken: (secret: string) => string;
1121
1224
 
1225
+ export declare interface IssuedRegisteredSession extends IssuedSession {
1226
+ /** The row's id — what `revokeSession` takes. */
1227
+ readonly sessionId: string;
1228
+ }
1229
+
1122
1230
  export declare interface IssuedSession {
1123
1231
  /** The opaque cookie value — `<payload>.<sig>`. */
1124
1232
  readonly value: string;
@@ -1126,6 +1234,18 @@ export declare interface IssuedSession {
1126
1234
  readonly setCookie: string;
1127
1235
  }
1128
1236
 
1237
+ /**
1238
+ * `issueSession` with a row behind it: writes the session to the registry,
1239
+ * stamps its id into the cookie and signs. A cookie issued here can be killed
1240
+ * before its `exp`; one issued with plain `issueSession` cannot.
1241
+ */
1242
+ export declare const issueRegisteredSession: (registry: Pick<SessionRegistry, "insertSession">, subject: Subject | SubjectIdentity, secret: SessionSecretInput, options?: IssueRegisteredSessionOptions) => Promise<IssuedRegisteredSession>;
1243
+
1244
+ export declare interface IssueRegisteredSessionOptions extends IssueSessionOptions {
1245
+ readonly ipAddress?: string | null;
1246
+ readonly userAgent?: string | null;
1247
+ }
1248
+
1129
1249
  /**
1130
1250
  * Mint a signed session AND build the corresponding `Set-Cookie`
1131
1251
  * header in one call. Apps just attach the header to their
@@ -1144,6 +1264,8 @@ export declare interface IssueSessionOptions {
1144
1264
  readonly ttlSeconds?: number;
1145
1265
  readonly domain?: string;
1146
1266
  readonly secure?: boolean;
1267
+ /** Clock override (epoch ms) — a testing seam for back-dated sessions. */
1268
+ readonly now?: () => number;
1147
1269
  }
1148
1270
 
1149
1271
  /**
@@ -1159,6 +1281,11 @@ export declare interface IssueSessionOptions {
1159
1281
  */
1160
1282
  export declare const issueUserSession: (user: UserRecord, store: MembershipStore & SessionStore & LockoutStore, config: AuthConfig, meta?: IssueUserSessionOptions) => Effect.Effect<{
1161
1283
  readonly setCookie: string;
1284
+ /** The signed session value itself — what a cookie-less client presents on
1285
+ * the `x-voltro-session` header (`/auth/native/exchange` returns it). */
1286
+ readonly value: string;
1287
+ /** Unix seconds the session expires. */
1288
+ readonly expiresAt: number;
1162
1289
  readonly subject: ReturnType<typeof subjectFromUser>;
1163
1290
  }>;
1164
1291
 
@@ -1201,6 +1328,13 @@ export declare interface IssueUserSessionOptions {
1201
1328
  readonly clearLoginFailures?: boolean;
1202
1329
  }
1203
1330
 
1331
+ /** Someone about to join a tenant. */
1332
+ export declare interface Joining {
1333
+ readonly email: string;
1334
+ readonly tenantId: string;
1335
+ readonly via: 'sign-up' | 'invitation';
1336
+ }
1337
+
1204
1338
  export { KeyedSecret }
1205
1339
 
1206
1340
  declare interface ListSessionsInput {
@@ -1431,6 +1565,40 @@ export declare const mintSecret: (ttlSeconds: number) => Omit<MintedToken, "purp
1431
1565
  */
1432
1566
  export declare const mintToken: (purpose: TokenPurpose, ttlSeconds?: number) => MintedToken;
1433
1567
 
1568
+ /** Lifetime of a native-login one-time code: the app redeems it within
1569
+ * seconds of the browser redirect; five minutes covers a slow hand-off. */
1570
+ export declare const NATIVE_CODE_TTL_S: number;
1571
+
1572
+ export declare const NATIVE_PENDING_COOKIE: "voltro:native";
1573
+
1574
+ export declare interface NativeExchangeInput {
1575
+ readonly code: string;
1576
+ readonly codeVerifier: string;
1577
+ }
1578
+
1579
+ export declare interface NativeLoginConfig {
1580
+ /** Exact redirect URIs the app may name — a custom scheme
1581
+ * (`acme://auth/callback`) or an https universal link. No wildcards. */
1582
+ readonly redirectUris: ReadonlyArray<string>;
1583
+ /** The web app's sign-in page, relative to the app origin. Default `/login`.
1584
+ * It must honour `?next=` (the auth template does). */
1585
+ readonly loginPath?: string;
1586
+ /** Lifetime of the pending request cookie. Default 10 minutes. */
1587
+ readonly pendingTtlSeconds?: number;
1588
+ /** Cookie name for the pending request. Default `voltro:native`. */
1589
+ readonly pendingCookieName?: string;
1590
+ }
1591
+
1592
+ /** Where the browser goes to sign in, coming back to `/native/complete`. */
1593
+ export declare const nativeLoginRedirect: (deps: NativeRouteDeps) => string;
1594
+
1595
+ export declare interface NativeRouteDeps {
1596
+ readonly prefix: string;
1597
+ readonly config: AuthConfig;
1598
+ readonly native: NativeLoginConfig;
1599
+ readonly now?: () => number;
1600
+ }
1601
+
1434
1602
  /**
1435
1603
  * Does this stored hash use parameters weaker than the framework's
1436
1604
  * current cost? `true` means "re-hash on next successful verify". A
@@ -1586,6 +1754,19 @@ export declare interface PostgresUserStoreOptions {
1586
1754
  */
1587
1755
  export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => SubjectIdentity | null;
1588
1756
 
1757
+ /**
1758
+ * Keyed read over the WHOLE header bag: the `x-voltro-session` header a
1759
+ * cookie-less client presents (React Native, a desktop shell — the value it
1760
+ * obtained through the native login exchange), or the session cookie a browser
1761
+ * carries. Same verify, same keyed rotation, same renewal signal as
1762
+ * `readSessionKeyed`; only where the value comes from differs.
1763
+ *
1764
+ * `readSessionKeyed` stays for the callers that hold a cookie string (an SSR
1765
+ * layout reading `request.headers.cookie`); this is what the routes and the
1766
+ * rpc strategy use, because they must accept both.
1767
+ */
1768
+ export declare const readSessionFromHeaders: (headers: Readonly<Record<string, string | undefined>>, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null;
1769
+
1589
1770
  /**
1590
1771
  * Keyed read: the full `VerifyResult` — subject + the `kid` that
1591
1772
  * verified + the sliding-window `renew` flag + `exp`/`iat`. The auth
@@ -1720,6 +1901,10 @@ export declare interface SessionRecord {
1720
1901
  readonly lastSeenAt: Date;
1721
1902
  }
1722
1903
 
1904
+ /** The session rows an app registers and revokes — the session half of a
1905
+ * `UserStore`, usable without the rest of it. */
1906
+ export declare type SessionRegistry = SessionStore;
1907
+
1723
1908
  export declare interface SessionRevocationChecker {
1724
1909
  /** Is this session still live? `false` means the row was revoked (or
1725
1910
  * is past its `expiresAt`) — the caller rejects the cookie. Verdicts
@@ -1789,6 +1974,10 @@ export declare interface SessionStore {
1789
1974
  /** Revoke every session for the user EXCEPT `keepSessionId` — the
1790
1975
  * "sign out other devices" action. Returns the count removed. */
1791
1976
  readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
1977
+ /** Revoke every session for the user — "sign out everywhere": a password
1978
+ * reset, a deactivation, an account deletion, a security event. Returns
1979
+ * the count removed. */
1980
+ readonly revokeAllSessions: (userId: string) => Effect.Effect<number>;
1792
1981
  }
1793
1982
 
1794
1983
  export declare interface SignInInput {
@@ -1896,7 +2085,7 @@ export declare interface SwitchTenantInput {
1896
2085
  readonly metadata?: Record<string, unknown>;
1897
2086
  }
1898
2087
 
1899
- export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify';
2088
+ export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify' | 'native-code';
1900
2089
 
1901
2090
  /** A single-use, hashed, expiring token (magic-link / password-reset). */
1902
2091
  export declare interface TokenRecord {
@@ -2023,6 +2212,15 @@ export declare interface UserRecord {
2023
2212
  export declare interface UserStore extends IdentityStore, MfaStore, MembershipStore, SessionStore, TokenStore, InvitationStore, ImpersonationStore, PasskeyStore, LockoutStore {
2024
2213
  }
2025
2214
 
2215
+ /** What a resolver is handed once the app's database is open. */
2216
+ export declare interface UserStoreBindContext {
2217
+ /** The framework's open pool — absent when the app runs `store: 'memory'`. */
2218
+ readonly sql?: SqlClient;
2219
+ }
2220
+
2221
+ /** Builds the `UserStore` once the framework has opened the app's database. */
2222
+ export declare type UserStoreResolver = (ctx: UserStoreBindContext) => UserStore;
2223
+
2026
2224
  /** Verify a passkey assertion (sign-in). */
2027
2225
  export declare const verifyAssertion: (input: AssertionVerifyInput) => AssertionOutcome;
2028
2226
 
@@ -2100,8 +2298,23 @@ export declare interface VoltroPasswordStrategyOptions {
2100
2298
  * routes and the rpc auth chain. Ignored without a `store`
2101
2299
  * (unless a checker is passed directly). */
2102
2300
  readonly revocation?: SessionRevocationOptions | SessionRevocationChecker;
2301
+ /** Session rows to check cookies against when the app mints its own
2302
+ * sessions (`issueRegisteredSession`): a registry, or a factory over the
2303
+ * app's store (`dataStoreSessionRegistry`) — built once per store, from
2304
+ * the `store` the framework hands every strategy. The checker is tuned by
2305
+ * `revocation` (options) as for `store`. */
2306
+ readonly sessionRegistry?: SessionRegistry | ((store: DataStore) => SessionRegistry);
2307
+ /** Refuse a cookie that carries no `metadata.sessionId` — one minted
2308
+ * outside the registry. Without this such a cookie is accepted as before
2309
+ * (stateless); with it, nothing but a registered session authenticates,
2310
+ * and every session can be revoked. */
2311
+ readonly requireSessionId?: boolean;
2103
2312
  }
2104
2313
 
2105
2314
  export declare type WebAuthnError = 'bad-client-data' | 'wrong-type' | 'challenge-mismatch' | 'origin-mismatch' | 'rpid-mismatch' | 'user-not-present' | 'bad-auth-data' | 'no-credential' | 'unsupported-key' | 'bad-signature' | 'counter-replay';
2106
2315
 
2316
+ /** The subject with a session row's id stamped into its metadata — what the
2317
+ * strategy reads back (`sessionIdOfSubject`) to check the row. */
2318
+ export declare const withSessionId: <S extends Subject | SubjectIdentity>(subject: S, sessionId: string) => S;
2319
+
2107
2320
  export { }