@voltro/plugin-auth 0.86.0 → 0.88.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 +5484 -0
- package/README.md +83 -0
- package/THIRD-PARTY-NOTICES.md +3 -227
- package/dist/admission.js +1 -2
- package/dist/appUserStore.js +1 -2
- package/dist/authExtensions.js +8 -0
- package/dist/csrf.js +1 -2
- package/dist/emailVerification.js +14 -13
- package/dist/guest.d.ts +99 -0
- package/dist/guest.js +3 -0
- package/dist/guestClient.js +236 -0
- package/dist/guestCredential.js +11 -0
- package/dist/guestPasswordUpgrade.js +84 -0
- package/dist/guestPlugin.js +165 -0
- package/dist/guestSchema.js +19 -0
- package/dist/guestSecrets.js +9 -0
- package/dist/guestService.js +223 -0
- package/dist/handlers.js +114 -115
- package/dist/impersonation.js +80 -80
- package/dist/index.d.ts +265 -15
- package/dist/index.js +27 -19
- package/dist/invitations.js +24 -25
- package/dist/mail.js +1 -2
- package/dist/memberships.js +1 -2
- package/dist/native/guestStorage.js +40 -0
- package/dist/native.d.ts +110 -0
- package/dist/native.js +46 -45
- package/dist/nativeRoutes.js +62 -74
- package/dist/overlayPlugin.js +166 -0
- package/dist/passkeyHandlers.js +1 -2
- package/dist/password.js +1 -2
- package/dist/plugin.d.ts +119 -10
- package/dist/plugin.js +100 -80
- package/dist/postgresUserStore.js +20 -9
- package/dist/revocation.js +41 -25
- package/dist/schema.d.ts +41 -1
- package/dist/schema.js +100 -100
- package/dist/session.js +1 -2
- package/dist/sessionFamily.js +14 -0
- package/dist/sessionRegistry.js +94 -80
- package/dist/signedClaims.js +23 -0
- package/dist/strategy.d.ts +13 -9
- package/dist/strategy.js +27 -24
- package/dist/subjectGuard.js +1 -2
- package/dist/tokens.js +1 -2
- package/dist/totp.js +1 -2
- package/dist/userStore.js +146 -140
- package/dist/web/guestStorage.js +46 -0
- package/dist/web/passkeyClient.js +1 -2
- package/dist/web/requireAuth.js +1 -2
- package/dist/web/subjectContext.js +1 -2
- package/dist/web.d.ts +94 -0
- package/dist/web.js +4 -2
- package/dist/webauthn.js +1 -2
- package/package.json +9 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { sessionSecretsOf as e } from "./session.js";
|
|
2
|
+
import { isSessionCredentialLiveFor as t } from "./sessionFamily.js";
|
|
3
|
+
import { dataStoreSessionRegistry as n } from "./sessionRegistry.js";
|
|
4
|
+
import { validateGuestSecrets as r } from "./guestSecrets.js";
|
|
5
|
+
import { authTokensTable as i, sessionsTable as a } from "./schema.js";
|
|
6
|
+
import { createCipheriv as o, createDecipheriv as s, createHash as c, hkdfSync as l, randomBytes as u } from "node:crypto";
|
|
7
|
+
import { Duration as d, Effect as f } from "effect";
|
|
8
|
+
import { SESSION_COOKIE_NAME as p, SESSION_HEADER_NAME as m, buildSetCookie as h, resolveSessionSecrets as g, verifySessionKeyed as _ } from "@voltro/protocol/session";
|
|
9
|
+
import { definePlugin as v } from "@voltro/protocol";
|
|
10
|
+
// src/overlayPlugin.ts
|
|
11
|
+
var y = "overlay-code", b = "authTokens", x = 60, S = 300, C = (e, t) => ({
|
|
12
|
+
status: e,
|
|
13
|
+
body: JSON.stringify(t),
|
|
14
|
+
contentType: "application/json",
|
|
15
|
+
headers: { "cache-control": "no-store" }
|
|
16
|
+
}), w = (e) => `ovl_${c("sha256").update(e).digest("hex")}`, T = (e) => Buffer.from(l("sha256", e, "voltro:web-overlay:v1", "overlay-code", 32)), E = (e, t) => {
|
|
17
|
+
let n = u(12), r = o("aes-256-gcm", T(t), n), i = Buffer.concat([r.update(JSON.stringify({
|
|
18
|
+
v: e.value,
|
|
19
|
+
n: e.next,
|
|
20
|
+
exp: e.exp
|
|
21
|
+
}), "utf8"), r.final()]);
|
|
22
|
+
return Buffer.concat([
|
|
23
|
+
n,
|
|
24
|
+
r.getAuthTag(),
|
|
25
|
+
i
|
|
26
|
+
]).toString("base64url");
|
|
27
|
+
}, D = (e, t, n) => {
|
|
28
|
+
if (e.length < 40 || e.length > 12e3 || !/^[A-Za-z0-9_-]+$/.test(e)) return null;
|
|
29
|
+
let r = Buffer.from(e, "base64url");
|
|
30
|
+
if (r.length < 29) return null;
|
|
31
|
+
for (let e of t) try {
|
|
32
|
+
let t = s("aes-256-gcm", T(e), r.subarray(0, 12));
|
|
33
|
+
t.setAuthTag(r.subarray(12, 28));
|
|
34
|
+
let i = JSON.parse(Buffer.concat([t.update(r.subarray(28)), t.final()]).toString("utf8"));
|
|
35
|
+
return typeof i.v != "string" || typeof i.n != "string" || typeof i.exp != "number" || i.exp <= n ? null : {
|
|
36
|
+
value: i.v,
|
|
37
|
+
next: i.n,
|
|
38
|
+
exp: i.exp
|
|
39
|
+
};
|
|
40
|
+
} catch {}
|
|
41
|
+
return null;
|
|
42
|
+
}, O = (e) => !/^\/[A-Za-z0-9/_.~-]*$/.test(e) || e.startsWith("//") || e.split("/").some((e) => e === ".." || e === ".") ? null : e.length > 1 && e.endsWith("/") ? e.slice(0, -1) : e, k = (e, t) => {
|
|
43
|
+
if (e.length > 512 || /[\u0000-\u001f\\#]/.test(e)) return !1;
|
|
44
|
+
let n = O(e.split("?")[0] ?? "");
|
|
45
|
+
return n !== null && t.some((e) => e === "/" || n === e || n.startsWith(e + "/"));
|
|
46
|
+
}, A = async (r, i, a, o) => {
|
|
47
|
+
let s = _(r, e(i));
|
|
48
|
+
if (!s || s.subject.type !== "user") return null;
|
|
49
|
+
let c = s.subject.metadata?.sessionId;
|
|
50
|
+
if (typeof c != "string") return null;
|
|
51
|
+
let l = await f.runPromise(n(a).findSession(c));
|
|
52
|
+
return t(l, c, s.subject.id, o) ? {
|
|
53
|
+
subjectId: s.subject.id,
|
|
54
|
+
exp: s.exp
|
|
55
|
+
} : null;
|
|
56
|
+
}, j = (t) => {
|
|
57
|
+
let n = t.prefix ?? "/auth/overlay";
|
|
58
|
+
if (!/^\/[a-zA-Z0-9/_-]+$/.test(n) || n.endsWith("/")) throw Error("Overlay route prefix must be an absolute path without a trailing slash");
|
|
59
|
+
let o = t.paths.map((e) => O(e));
|
|
60
|
+
if (o.length === 0 || o.some((e) => e === null)) throw Error("Overlay paths must be absolute same-origin paths without traversal");
|
|
61
|
+
let s = o, c = d.toSeconds(t.ttl ?? `${x} seconds`);
|
|
62
|
+
if (!Number.isSafeInteger(c) || c < 1 || c > S) throw Error("Overlay ticket lifetime must be 1 to 300 whole seconds");
|
|
63
|
+
let l = () => r(t.secret ?? g()), _, T = async (t, r) => {
|
|
64
|
+
if (t.method !== "POST") return {
|
|
65
|
+
...C(405, { error: "method_not_allowed" }),
|
|
66
|
+
headers: {
|
|
67
|
+
"cache-control": "no-store",
|
|
68
|
+
allow: "POST"
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
if ((t.headers["content-type"] ?? "").split(";")[0]?.trim() !== "application/json") return C(415, { error: "json_required" });
|
|
72
|
+
if (t.rawBody.byteLength > 1024) return C(413, { error: "body_too_large" });
|
|
73
|
+
let i = t.headers[m];
|
|
74
|
+
if (!i) return C(401, { error: "session_header_required" });
|
|
75
|
+
let a;
|
|
76
|
+
try {
|
|
77
|
+
a = JSON.parse(new TextDecoder().decode(t.rawBody));
|
|
78
|
+
} catch {
|
|
79
|
+
return C(400, { error: "invalid_input" });
|
|
80
|
+
}
|
|
81
|
+
if (typeof a != "object" || !a || Array.isArray(a) || Object.keys(a).some((e) => e !== "next") || typeof a.next != "string") return C(400, { error: "invalid_input" });
|
|
82
|
+
let o = a.next;
|
|
83
|
+
if (!k(o, s)) return C(400, { error: "page_not_allowed" });
|
|
84
|
+
let d = Date.now(), f = await A(i, l(), r, d);
|
|
85
|
+
if (!f) return C(401, { error: "session_rejected" });
|
|
86
|
+
let p = Math.min(Math.floor(d / 1e3) + c, f.exp), h = u(32).toString("base64url");
|
|
87
|
+
await r.insert(b, {
|
|
88
|
+
id: w(h),
|
|
89
|
+
tokenHash: w(h).slice(4),
|
|
90
|
+
userId: f.subjectId,
|
|
91
|
+
purpose: y,
|
|
92
|
+
expiresAt: /* @__PURE__ */ new Date(p * 1e3),
|
|
93
|
+
consumedAt: null
|
|
94
|
+
});
|
|
95
|
+
let g = `${h}.${E({
|
|
96
|
+
value: i,
|
|
97
|
+
next: o,
|
|
98
|
+
exp: p
|
|
99
|
+
}, e(l()).current.secret)}`;
|
|
100
|
+
return C(200, {
|
|
101
|
+
url: `${n}/enter?code=${g}`,
|
|
102
|
+
expiresAt: p
|
|
103
|
+
});
|
|
104
|
+
}, j = async (n, r) => {
|
|
105
|
+
if (n.method !== "GET") return {
|
|
106
|
+
...C(405, { error: "method_not_allowed" }),
|
|
107
|
+
headers: {
|
|
108
|
+
"cache-control": "no-store",
|
|
109
|
+
allow: "GET"
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
let i = new URLSearchParams(n.query).get("code") ?? "", a = i.indexOf("."), o = i.slice(0, a);
|
|
113
|
+
if (a <= 0 || !/^[A-Za-z0-9_-]{43}$/.test(o)) return C(400, { error: "invalid_code" });
|
|
114
|
+
let c = e(l()), u = Date.now(), d = D(i.slice(a + 1), [c.current.secret, ...c.previous ? [c.previous.secret] : []], Math.floor(u / 1e3));
|
|
115
|
+
if (!await r.delete(b, w(o)) || !d || !k(d.next, s)) return C(400, { error: "invalid_code" });
|
|
116
|
+
if (!await A(d.value, c, r, u)) return C(401, { error: "session_rejected" });
|
|
117
|
+
let f = h(p, d.value, {
|
|
118
|
+
httpOnly: !0,
|
|
119
|
+
sameSite: "lax",
|
|
120
|
+
secure: t.secure ?? !0,
|
|
121
|
+
path: "/",
|
|
122
|
+
...t.domain ? { domain: t.domain } : {}
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
status: 302,
|
|
126
|
+
body: "",
|
|
127
|
+
headers: {
|
|
128
|
+
location: d.next,
|
|
129
|
+
"set-cookie": f,
|
|
130
|
+
"cache-control": "no-store",
|
|
131
|
+
"referrer-policy": "no-referrer"
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
return v({
|
|
136
|
+
name: "@voltro/plugin-auth#overlay",
|
|
137
|
+
baseName: "@voltro/plugin-auth",
|
|
138
|
+
permissions: ["store:write"],
|
|
139
|
+
extendSchema: { tables: [i, a] },
|
|
140
|
+
declaredEnv: t.secret === void 0 ? [{
|
|
141
|
+
name: "VOLTRO_SESSION_SECRET",
|
|
142
|
+
required: !0,
|
|
143
|
+
secret: !0,
|
|
144
|
+
generate: "base64url",
|
|
145
|
+
description: "Signs this project's sessions and seals web overlay tickets"
|
|
146
|
+
}] : [],
|
|
147
|
+
httpRoutes: [{
|
|
148
|
+
method: "*",
|
|
149
|
+
path: n,
|
|
150
|
+
handle: async (e) => {
|
|
151
|
+
let t = e.store ?? _;
|
|
152
|
+
return e.path !== n + "/ticket" && e.path !== n + "/enter" ? C(404, { error: "not_found" }) : t ? e.path === n + "/ticket" ? T(e, t) : j(e, t) : C(503, { error: "store_unavailable" });
|
|
153
|
+
}
|
|
154
|
+
}],
|
|
155
|
+
bindDataStore: (e) => {
|
|
156
|
+
_ = e;
|
|
157
|
+
},
|
|
158
|
+
onActivate: () => f.sync(() => {
|
|
159
|
+
l();
|
|
160
|
+
}),
|
|
161
|
+
onDeactivate: () => f.sync(() => {
|
|
162
|
+
_ = void 0;
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
export { y as OVERLAY_TOKEN_PURPOSE, k as allowedOverlayPage, j as overlayAuthPlugin };
|
package/dist/passkeyHandlers.js
CHANGED
|
@@ -3,7 +3,7 @@ import { newAuthId as t, subjectFromUser as n } from "./userStore.js";
|
|
|
3
3
|
import { configSessionSecrets as r, guardSubject as i } from "./handlers.js";
|
|
4
4
|
import { generateChallenge as a, verifyAssertion as o, verifyRegistration as s } from "./webauthn.js";
|
|
5
5
|
import { Effect as c } from "effect";
|
|
6
|
-
|
|
6
|
+
// src/passkeyHandlers.ts
|
|
7
7
|
var l = (e, t, n = {}) => ({
|
|
8
8
|
status: e,
|
|
9
9
|
body: JSON.stringify(t),
|
|
@@ -158,5 +158,4 @@ var l = (e, t, n = {}) => ({
|
|
|
158
158
|
subject: C
|
|
159
159
|
}, { setCookie: w.setCookie });
|
|
160
160
|
});
|
|
161
|
-
//#endregion
|
|
162
161
|
export { f as dataStoreChallengeStore, g as handlePasskeyAssertOptions, _ as handlePasskeyAssertVerify, m as handlePasskeyRegisterOptions, h as handlePasskeyRegisterVerify, d as memoryChallengeStore };
|
package/dist/password.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes as e, scrypt as t, timingSafeEqual as n } from "node:crypto";
|
|
2
2
|
import { Data as r, Effect as i } from "effect";
|
|
3
|
-
|
|
3
|
+
// src/password.ts
|
|
4
4
|
var a = 32768, o = 8, s = 1, c = 32, l = 16, u = (e, t) => 128 * e * t * 2, d = 268435456, f = class extends r.TaggedError("PasswordEmptyError") {}, p = class extends r.TaggedError("PasswordHashError") {}, m = (e, n, r, a) => i.callback((o) => {
|
|
5
5
|
t(e, n, r, {
|
|
6
6
|
...a,
|
|
@@ -71,5 +71,4 @@ var a = 32768, o = 8, s = 1, c = 32, l = 16, u = (e, t) => 128 * e * t * 2, d =
|
|
|
71
71
|
rehash: n
|
|
72
72
|
} : { valid: !0 };
|
|
73
73
|
});
|
|
74
|
-
//#endregion
|
|
75
74
|
export { f as PasswordEmptyError, p as PasswordHashError, h as hashPassword, y as needsRehash, v as parseScryptParams, g as verifyPassword, b as verifyPasswordWithRehash };
|
package/dist/plugin.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Effect } from 'effect';
|
|
|
5
5
|
import { SessionSecrets } from '@voltro/protocol/session';
|
|
6
6
|
import { SqlClient } from 'effect/sql/SqlClient';
|
|
7
7
|
import { Subject } from '@voltro/protocol';
|
|
8
|
+
import { SubjectIdentity } from '@voltro/protocol';
|
|
8
9
|
import { VoidIfEmpty } from 'effect/Types';
|
|
9
10
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
10
11
|
import { YieldableError } from 'effect/Cause';
|
|
@@ -124,7 +125,7 @@ declare interface AuthConfig {
|
|
|
124
125
|
* routes use) so the framework's serve pipeline can slot it into the
|
|
125
126
|
* per-app auth chain — the rpc/WS path then rejects revoked sessions
|
|
126
127
|
* exactly like the `/auth/*` routes. Read it via
|
|
127
|
-
* `
|
|
128
|
+
* `getAuthSessionStrategies`.
|
|
128
129
|
*/
|
|
129
130
|
export declare interface AuthPlugin extends VoltroPlugin {
|
|
130
131
|
readonly auth: {
|
|
@@ -174,6 +175,15 @@ export declare interface AuthRoutesPluginOptions extends AuthConfig {
|
|
|
174
175
|
* otherwise get 500s from a route it never asked for.
|
|
175
176
|
*/
|
|
176
177
|
readonly invitations?: InvitationsConfig;
|
|
178
|
+
/**
|
|
179
|
+
* Guest → account with email and password. PRESENT mounts `POST /auth/upgrade-guest`
|
|
180
|
+
* (`{ credential, email, password }`, CSRF): the guest becomes a registered account under its
|
|
181
|
+
* own subject ID, so what it owns stays its own. Pass the installed guest lifecycle port,
|
|
182
|
+
* e.g. `{ service: () => guestPlugin.guest.service() }`. ABSENT mounts nothing.
|
|
183
|
+
*/
|
|
184
|
+
readonly guestUpgrade?: {
|
|
185
|
+
readonly service: () => GuestAuthService;
|
|
186
|
+
};
|
|
177
187
|
/**
|
|
178
188
|
* User impersonation ("log in as"). PRESENT mounts `/auth/impersonate/start`
|
|
179
189
|
* + `/stop`, arms the retention bound on `impersonationGrants`, and closes
|
|
@@ -242,11 +252,12 @@ declare interface EmailVerificationConfig {
|
|
|
242
252
|
declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict';
|
|
243
253
|
|
|
244
254
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
255
|
+
* Collect pre-wired session strategies in plugin declaration order. The
|
|
256
|
+
* framework inserts each original strategy into its shared auth chain, so
|
|
257
|
+
* prefix ordering, collision diagnostics and boot verification see them all.
|
|
258
|
+
* Only `skip` advances; rejected credentials never get another attempt.
|
|
248
259
|
*/
|
|
249
|
-
export declare const
|
|
260
|
+
export declare const getAuthSessionStrategies: (plugins: ReadonlyArray<VoltroPlugin>) => ReadonlyArray<AuthStrategy>;
|
|
250
261
|
|
|
251
262
|
declare interface GrantRoleContext {
|
|
252
263
|
/** The inviter's role in THIS tenant, as read from `memberships`. */
|
|
@@ -256,6 +267,91 @@ declare interface GrantRoleContext {
|
|
|
256
267
|
readonly tenantId: string;
|
|
257
268
|
}
|
|
258
269
|
|
|
270
|
+
declare class GuestAuthError extends GuestAuthError_base<{
|
|
271
|
+
readonly code: 'credential_rejected' | 'store_unavailable' | 'upgrade_conflict';
|
|
272
|
+
}> {
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
declare const GuestAuthError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
|
|
276
|
+
readonly _tag: "GuestAuthError";
|
|
277
|
+
} & Readonly<A>;
|
|
278
|
+
|
|
279
|
+
/** Durable guest lifecycle port. Implementations must serialize every write
|
|
280
|
+
* against the guest identity and commit session rows before returning credentials. */
|
|
281
|
+
declare interface GuestAuthService {
|
|
282
|
+
readonly create: () => Effect.Effect<GuestSession, GuestAuthError>;
|
|
283
|
+
readonly resume: (credential: string) => Effect.Effect<GuestSession, GuestAuthError>;
|
|
284
|
+
/** Propose a fresh server-minted proof without replacing the active proof. */
|
|
285
|
+
readonly prepareRotation: (credential: string) => Effect.Effect<{
|
|
286
|
+
readonly deviceCredential: string;
|
|
287
|
+
}, GuestAuthError>;
|
|
288
|
+
/** The caller durably journals both proofs before this atomic replacement. */
|
|
289
|
+
readonly rotate: (credential: string, nextCredential: string) => Effect.Effect<GuestSession, GuestAuthError>;
|
|
290
|
+
readonly revoke: (credential: string) => Effect.Effect<void, GuestAuthError>;
|
|
291
|
+
/** Server-side privacy hook: the caller must authorize this subject ID. */
|
|
292
|
+
readonly erase: (subjectId: string) => Effect.Effect<boolean, GuestAuthError>;
|
|
293
|
+
/** Server-side privacy hook: excludes credential hashes and credential bytes. */
|
|
294
|
+
readonly export: (subjectId: string) => Effect.Effect<GuestExport | null, GuestAuthError>;
|
|
295
|
+
readonly isLive: (subject: SubjectIdentity) => Effect.Effect<boolean, GuestAuthError>;
|
|
296
|
+
/** Trusted server operation. The writer must use this transaction to create
|
|
297
|
+
* the registered `users` row with exactly the guest ID and tenant. The guest
|
|
298
|
+
* fence, writer, device removal and guest-session revocation commit together.
|
|
299
|
+
* No client-supplied kind/state can authorize an upgrade. */
|
|
300
|
+
readonly upgrade: <A>(credential: string, writer: GuestUpgradeWriter<A>) => Effect.Effect<A, GuestAuthError>;
|
|
301
|
+
/** Trusted, separately confirmed merge into an EXISTING registered subject.
|
|
302
|
+
* The coordinator must prove target ownership and run all data hooks through
|
|
303
|
+
* this same transaction. A device proof alone never proves target ownership. */
|
|
304
|
+
readonly mergeInto: <A>(credential: string, targetId: string, writer: GuestMergeWriter<A>) => Effect.Effect<A, GuestAuthError>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
declare interface GuestExport {
|
|
308
|
+
readonly subject: {
|
|
309
|
+
readonly id: string;
|
|
310
|
+
readonly tenantId: string;
|
|
311
|
+
readonly kind: 'guest';
|
|
312
|
+
readonly upgradeable: boolean;
|
|
313
|
+
readonly createdAt: number;
|
|
314
|
+
readonly expiresAt: number;
|
|
315
|
+
};
|
|
316
|
+
readonly devices: ReadonlyArray<{
|
|
317
|
+
readonly id: string;
|
|
318
|
+
readonly generation: number;
|
|
319
|
+
readonly createdAt: number;
|
|
320
|
+
readonly expiresAt: number;
|
|
321
|
+
}>;
|
|
322
|
+
readonly sessions: ReadonlyArray<{
|
|
323
|
+
readonly id: string;
|
|
324
|
+
readonly expiresAt: number;
|
|
325
|
+
}>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
declare type GuestIdentity = Extract<SubjectIdentity, {
|
|
329
|
+
readonly type: 'user';
|
|
330
|
+
}> & {
|
|
331
|
+
readonly kind: 'guest';
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
declare interface GuestMergeContext extends GuestUpgradeContext {
|
|
335
|
+
readonly targetId: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
declare type GuestMergeWriter<A> = (context: GuestMergeContext) => Promise<A>;
|
|
339
|
+
|
|
340
|
+
declare interface GuestSession extends IssuedRegisteredSession {
|
|
341
|
+
readonly subject: GuestIdentity;
|
|
342
|
+
/** Opaque proof returned only on creation or explicit credential rotation. */
|
|
343
|
+
readonly deviceCredential?: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
declare interface GuestUpgradeContext {
|
|
347
|
+
readonly store: DataStore;
|
|
348
|
+
readonly subjectId: string;
|
|
349
|
+
readonly tenantId: string;
|
|
350
|
+
readonly createdAt: Date;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
declare type GuestUpgradeWriter<A> = (context: GuestUpgradeContext) => Promise<A>;
|
|
354
|
+
|
|
259
355
|
/** Identity — who a user is. The only role an app with its own identity table has to write itself; everything else composes from the shipped store. */
|
|
260
356
|
declare interface IdentityStore {
|
|
261
357
|
readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
|
|
@@ -470,6 +566,18 @@ declare interface InvitationStore {
|
|
|
470
566
|
readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
|
|
471
567
|
}
|
|
472
568
|
|
|
569
|
+
declare interface IssuedRegisteredSession extends IssuedSession {
|
|
570
|
+
/** The row's id — what `revokeSession` takes. */
|
|
571
|
+
readonly sessionId: string;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
declare interface IssuedSession {
|
|
575
|
+
/** The opaque cookie value — `<payload>.<sig>`. */
|
|
576
|
+
readonly value: string;
|
|
577
|
+
/** Ready-to-Set-Cookie header string with sane defaults. */
|
|
578
|
+
readonly setCookie: string;
|
|
579
|
+
}
|
|
580
|
+
|
|
473
581
|
/** Someone about to join a tenant. */
|
|
474
582
|
declare interface Joining {
|
|
475
583
|
readonly email: string;
|
|
@@ -679,13 +787,14 @@ declare interface SessionRevocationOptions {
|
|
|
679
787
|
|
|
680
788
|
/** Server-side sessions — enumeration + revocation. */
|
|
681
789
|
declare interface SessionStore {
|
|
682
|
-
/**
|
|
790
|
+
/** Unrotated credentials with an existing canonical root owned by the same user, most recent first. */
|
|
683
791
|
readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
|
|
684
792
|
/** Record a session on sign-in. */
|
|
685
793
|
readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
|
|
686
|
-
/** Look up
|
|
687
|
-
*
|
|
688
|
-
*
|
|
794
|
+
/** Look up an unrotated credential only while its canonical family root
|
|
795
|
+
* exists and belongs to the same user. A rotated root remains an anchor;
|
|
796
|
+
* its tenant does not replace the credential tenant. Custom stores must
|
|
797
|
+
* enforce this before returning a row to request-time revocation checks. */
|
|
689
798
|
readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
|
|
690
799
|
/** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
|
|
691
800
|
* when the sliding-window renewal re-issues the cookie, so the row
|
|
@@ -777,7 +886,7 @@ declare const UserNotFoundError_base: new <A extends Record<string, any> = {}>(a
|
|
|
777
886
|
|
|
778
887
|
declare interface UserRecord {
|
|
779
888
|
readonly id: string;
|
|
780
|
-
readonly email: string;
|
|
889
|
+
readonly email: string | null;
|
|
781
890
|
/**
|
|
782
891
|
* The stored scrypt hash — OPTIONAL, because not every identity has a
|
|
783
892
|
* password. An SSO-only, magic-link-only, passkey-only, or provider-PAT
|