@substrat-run/vertical-auth 0.6.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,73 @@
1
+ import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
2
+ import { sql } from 'drizzle-orm';
3
+ /**
4
+ * Better Auth's standard tables (user/session/account/verification) as a Drizzle schema,
5
+ * for the per-tenant identity DO's own SQLite. Timestamps are integer epoch-ms; the
6
+ * sub→principal binding is NOT here — it lives in the identity DO's own `identity` table,
7
+ * provider-agnostic (a login authenticated by OIDC has no Better Auth `user` row).
8
+ */
9
+ export const user = sqliteTable('user', {
10
+ id: text('id').primaryKey(),
11
+ name: text('name').notNull(),
12
+ email: text('email').notNull().unique(),
13
+ emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
14
+ image: text('image'),
15
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
16
+ .default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
17
+ .notNull(),
18
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
19
+ .default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
20
+ .$onUpdate(() => /* @__PURE__ */ new Date())
21
+ .notNull(),
22
+ });
23
+ export const session = sqliteTable('session', {
24
+ id: text('id').primaryKey(),
25
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
26
+ token: text('token').notNull().unique(),
27
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
28
+ .default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
29
+ .notNull(),
30
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
31
+ .$onUpdate(() => /* @__PURE__ */ new Date())
32
+ .notNull(),
33
+ ipAddress: text('ip_address'),
34
+ userAgent: text('user_agent'),
35
+ userId: text('user_id')
36
+ .notNull()
37
+ .references(() => user.id, { onDelete: 'cascade' }),
38
+ }, (table) => [index('session_userId_idx').on(table.userId)]);
39
+ export const account = sqliteTable('account', {
40
+ id: text('id').primaryKey(),
41
+ accountId: text('account_id').notNull(),
42
+ providerId: text('provider_id').notNull(),
43
+ userId: text('user_id')
44
+ .notNull()
45
+ .references(() => user.id, { onDelete: 'cascade' }),
46
+ accessToken: text('access_token'),
47
+ refreshToken: text('refresh_token'),
48
+ idToken: text('id_token'),
49
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
50
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
51
+ scope: text('scope'),
52
+ password: text('password'),
53
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
54
+ .default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
55
+ .notNull(),
56
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
57
+ .$onUpdate(() => /* @__PURE__ */ new Date())
58
+ .notNull(),
59
+ }, (table) => [index('account_userId_idx').on(table.userId)]);
60
+ export const verification = sqliteTable('verification', {
61
+ id: text('id').primaryKey(),
62
+ identifier: text('identifier').notNull(),
63
+ value: text('value').notNull(),
64
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
65
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
66
+ .default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
67
+ .notNull(),
68
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
69
+ .default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
70
+ .$onUpdate(() => /* @__PURE__ */ new Date())
71
+ .notNull(),
72
+ }, (table) => [index('verification_identifier_idx').on(table.identifier)]);
73
+ //# sourceMappingURL=auth-schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-schema.js","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export declare function resolveCookieDomain(configured: string | undefined, host: string): string | null;
2
+ //# sourceMappingURL=cookie-domain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie-domain.d.ts","sourceRoot":"","sources":["../src/cookie-domain.ts"],"names":[],"mappings":"AAuBA,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAW/F"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The shared-session cookie domain (vertical-auth-detach.md; the multi-surface case,
3
+ * K-26): a scope whose surfaces are sibling hostnames — `crm.egeryds.se`,
4
+ * `eka.egeryds.se`, … — shares ONE login by setting the session cookie with
5
+ * `Domain=egeryds.se`. The signing secret is already per-tenant (DO-minted), so the
6
+ * cookie verifies on every surface; the Domain attribute is the only missing piece.
7
+ *
8
+ * The domain is per-scope DELIVERED config (`substrat:auth`), never code, and it is
9
+ * validated HERE, where the cookie is set: it must cover the request host (equal, or a
10
+ * proper suffix at a label boundary) and must not be a bare TLD. A domain that fails
11
+ * validation degrades to a host-only cookie — sessions simply don't share — because a
12
+ * misdelivered config must never break sign-in.
13
+ *
14
+ * The registrable-suffix half is now enforced HERE too, not only upstream (#305, D-35):
15
+ * a configured domain that is itself a public suffix — `co.uk`, `pages.dev`, or any
16
+ * multi-level registry suffix a label-count check would miss — is rejected via the
17
+ * vendored Public Suffix List (`@substrat-run/psl`). A cookie on a public suffix spans
18
+ * every tenant registered under it, so it must never be honoured. The platform's own
19
+ * apex (`substrat.run`) is an ordinary registrable domain, NOT a public suffix, so it is
20
+ * additionally guarded upstream (the platform refuses to deliver it as a cookie-domain).
21
+ */
22
+ import { isPublicSuffix } from '@substrat-run/psl';
23
+ export function resolveCookieDomain(configured, host) {
24
+ if (!configured)
25
+ return null;
26
+ const domain = configured.trim().toLowerCase().replace(/^\./, '');
27
+ if (!domain.includes('.'))
28
+ return null; // a bare TLD is never a session boundary
29
+ const h = host.toLowerCase();
30
+ if (h !== domain && !h.endsWith(`.${domain}`))
31
+ return null; // browser would reject it anyway
32
+ // Registrable-suffix guard (D-35): a cookie whose Domain is a public suffix — `co.uk`,
33
+ // `pages.dev`, any multi-level registry suffix a label-count check misses — spans every
34
+ // tenant under it. Reject it; the session degrades to host-only rather than leaking.
35
+ if (isPublicSuffix(domain))
36
+ return null;
37
+ return domain;
38
+ }
39
+ //# sourceMappingURL=cookie-domain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie-domain.js","sourceRoot":"","sources":["../src/cookie-domain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,MAAM,UAAU,mBAAmB,CAAC,UAA8B,EAAE,IAAY;IAC9E,IAAI,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IAC7B,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAClE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,yCAAyC;IACjF,MAAM,CAAC,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IAC7B,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,iCAAiC;IAC7F,uFAAuF;IACvF,wFAAwF;IACxF,qFAAqF;IACrF,IAAI,cAAc,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IACxC,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,161 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import type { AuthProvider } from './provider.js';
3
+ import { type SiteRow } from './site-registry.js';
4
+ export type IdentityDoEnv = Record<string, never>;
5
+ export declare class IdentityDO extends DurableObject<IdentityDoEnv> {
6
+ /** This tenant's Better Auth signing secret — generated in this DO, never shared, never a worker binding. */
7
+ private authSecret;
8
+ constructor(ctx: DurableObjectState, env: IdentityDoEnv);
9
+ /**
10
+ * Upsert per-scope config delivered by the platform (vertical-auth-detach.md §2.2).
11
+ * Key-by-key, never a replace, so partial deliveries compose; idempotent so the
12
+ * reconciliation sweep can re-run it.
13
+ */
14
+ setScopeConfig(scopeId: string, entries: Array<{
15
+ key: string;
16
+ value: string;
17
+ }>): Promise<void>;
18
+ /**
19
+ * The config DELIVERED to one scope (dashboard Env tab → `/internal/configure` →
20
+ * `setScopeConfig`), as a plain map — the `delivered` argument of the contracts helper
21
+ * `resolveScopedEnvSpec(spec, env, delivered)`. This is the blessed read for a
22
+ * vertical's ORDINARY env-spec keys (issue #398): overlay these values on worker env,
23
+ * per-scope wins. `authWiring` below is the auth-specific composite (config + session
24
+ * secret in one hop); use that when you also need the secret, this when you don't.
25
+ */
26
+ getScopeConfig(scopeId: string): Promise<Record<string, string>>;
27
+ /**
28
+ * Everything the worker needs to build the scope's `AuthProvider`, in ONE round-trip:
29
+ * the scope's delivered config (notably `substrat:auth`) plus this tenant's
30
+ * session-signing secret (minted here on first use, never a worker binding — the same
31
+ * per-tenant-secret move as `auth_secret`). One method rather than two keeps the
32
+ * per-request cost at a single DO hop.
33
+ */
34
+ authWiring(scopeId: string): Promise<{
35
+ config: Record<string, string>;
36
+ sessionSecret: string;
37
+ }>;
38
+ /**
39
+ * Record a SITE (scope) at provision — the vertical's own memory of its sites (K-26
40
+ * multi-scope), used to enumerate and switch between them without reaching the control-plane
41
+ * directory. Idempotent: a re-provision (K-31) updates the slug/name but keeps `created_at`,
42
+ * so the switcher's ordering is stable.
43
+ */
44
+ recordSite(scopeId: string, slug: string, name: string): Promise<void>;
45
+ /** Drop a site from the registry (the vertical's half of archiving it). Idempotent. */
46
+ forgetSite(scopeId: string): Promise<void>;
47
+ /** The tenant's sites, oldest first — the in-app site switcher's list. */
48
+ listSites(): Promise<SiteRow[]>;
49
+ /**
50
+ * Resolve a site SLUG to its scope id within this tenant — how the worker turns the app's
51
+ * `x-site` selection into the scope to open. Null ⇒ no such site (the caller falls back to the
52
+ * routed home scope). Tenant-scoped by construction: this DO is per-tenant, so a slug can only
53
+ * ever resolve to a scope of THIS tenant.
54
+ */
55
+ resolveSiteScope(slug: string): Promise<string | null>;
56
+ /** The DO's SQLite as the registry's minimal `exec` seam (see site-registry.ts). */
57
+ private get registrySql();
58
+ /**
59
+ * Record the owner seat at provision. Writes BOTH the transient `pending_owner` (claimed +
60
+ * consumed by the first login) and the durable `owner_of_record` (#332, never consumed) in one
61
+ * call, so the vertical always retains who the owner is even after the seat has been claimed.
62
+ * Idempotent — the reconciliation sweep re-runs it.
63
+ */
64
+ setPendingOwner(scopeId: string, principal: string): Promise<void>;
65
+ /**
66
+ * The scope's durable owner of record, or null if this scope was never provisioned through this
67
+ * DO (a legacy scope predating #332, or a slug typo). Read by the reconcile path to re-grant the
68
+ * owner their role after a scope-DO wipe left the scope enforcing against an empty tuple table.
69
+ */
70
+ getOwnerOfRecord(scopeId: string): Promise<string | null>;
71
+ /**
72
+ * Is this scope awaiting first-run setup? True while its owner seat is unclaimed — the
73
+ * instance is provisioned but no admin has signed in yet. The worker uses this to show a
74
+ * "create the admin account" setup screen instead of a plain sign-in, and to keep open
75
+ * sign-up allowed only during this window (after the owner claims, sign-up is invite-only).
76
+ */
77
+ needsSetup(scopeId: string): Promise<boolean>;
78
+ /**
79
+ * Map a verified subject to a PrincipalId in this scope. If already bound, return it. If
80
+ * not, and the scope's owner seat is unclaimed, CLAIM it: bind this subject to the owner
81
+ * principal and consume the pending seat. Otherwise null — a valid login with no seat has
82
+ * no access. Provider-agnostic: the subject may come from Better Auth or an OIDC issuer.
83
+ */
84
+ resolvePrincipal(scopeId: string, sub: string): Promise<string | null>;
85
+ /**
86
+ * Record a member invite: a pre-minted `principal` + `roleKey` the caller has already
87
+ * granted at scope level, claimable by whoever presents the token whose hash is `tokenHash`.
88
+ * The plaintext token never reaches the DO — only its hash, so a DB read can't mint access.
89
+ */
90
+ createInvite(scopeId: string, principal: string, roleKey: string, email: string | null, tokenHash: string): Promise<void>;
91
+ /** The scope's outstanding (unclaimed) invites — for the admin's pending-invites list. No token. */
92
+ listInvites(scopeId: string): Promise<Array<{
93
+ principal: string;
94
+ roleKey: string;
95
+ email: string | null;
96
+ createdAt: number;
97
+ }>>;
98
+ /** Is there an unclaimed invite for this token hash? (the sign-up gate consults this post-setup). */
99
+ inviteExists(scopeId: string, tokenHash: string): Promise<boolean>;
100
+ /** Withdraw an unclaimed invite by its (pre-minted) principal — the id the admin sees. */
101
+ revokeInvite(scopeId: string, principal: string): Promise<void>;
102
+ /**
103
+ * Claim an invite: bind this verified subject to the invite's pre-minted principal and
104
+ * consume the invite. Returns the principal, or null if no unclaimed invite matches the
105
+ * token hash. The role was granted at scope level when the invite was created, so the
106
+ * bound principal resolves its permissions immediately.
107
+ */
108
+ claimInvite(scopeId: string, sub: string, tokenHash: string): Promise<string | null>;
109
+ /** A Better Auth instance over THIS DO's SQLite, trusting the caller's origin. */
110
+ private auth;
111
+ /**
112
+ * The DO's HTTP surface (used only when Better Auth is the chosen provider). `/__session`
113
+ * resolves the request to an `AuthSubject`; everything else is a Better Auth request. The
114
+ * worker forwards requests here; Better Auth never runs in the worker.
115
+ *
116
+ * `x-substrat-cookie-domain` is the worker relaying the scope's delivered `substrat:auth`
117
+ * cookie-domain choice (the DO can't look it up itself — this fetch carries no scope id).
118
+ * The stub is reachable only from the worker, so the header is as trusted as the config;
119
+ * it is still re-validated against the request host before it touches a Set-Cookie.
120
+ */
121
+ fetch(request: Request): Promise<Response>;
122
+ }
123
+ /** A minimal stub shape — the identity DO's callable surface (avoids leaking the full class type). */
124
+ export type IdentityStub = {
125
+ fetch(request: Request): Promise<Response>;
126
+ setScopeConfig(scopeId: string, entries: Array<{
127
+ key: string;
128
+ value: string;
129
+ }>): Promise<void>;
130
+ getScopeConfig(scopeId: string): Promise<Record<string, string>>;
131
+ authWiring(scopeId: string): Promise<{
132
+ config: Record<string, string>;
133
+ sessionSecret: string;
134
+ }>;
135
+ setPendingOwner(scopeId: string, principal: string): Promise<void>;
136
+ getOwnerOfRecord(scopeId: string): Promise<string | null>;
137
+ needsSetup(scopeId: string): Promise<boolean>;
138
+ resolvePrincipal(scopeId: string, sub: string): Promise<string | null>;
139
+ createInvite(scopeId: string, principal: string, roleKey: string, email: string | null, tokenHash: string): Promise<void>;
140
+ listInvites(scopeId: string): Promise<Array<{
141
+ principal: string;
142
+ roleKey: string;
143
+ email: string | null;
144
+ createdAt: number;
145
+ }>>;
146
+ inviteExists(scopeId: string, tokenHash: string): Promise<boolean>;
147
+ revokeInvite(scopeId: string, principal: string): Promise<void>;
148
+ claimInvite(scopeId: string, sub: string, tokenHash: string): Promise<string | null>;
149
+ };
150
+ /**
151
+ * The `AuthProvider` backed by a tenant's identity-DO stub (the `better-auth-do` config).
152
+ * `handle` forwards the raw request; `resolve` asks the DO's `/__session` probe, carrying
153
+ * the request's cookies. The worker holds only this — never Better Auth itself.
154
+ *
155
+ * `cookieDomain` (the scope's delivered `substrat:auth` choice) rides to the DO as a
156
+ * header because the worker holds the config and the DO holds Better Auth — see fetch().
157
+ */
158
+ export declare function doAuthProvider(stub: Pick<IdentityStub, 'fetch'>, origin: string, opts?: {
159
+ cookieDomain?: string;
160
+ }): AuthProvider;
161
+ //# sourceMappingURL=identity-do.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity-do.d.ts","sourceRoot":"","sources":["../src/identity-do.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,326 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { betterAuth } from 'better-auth';
3
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
4
+ import { drizzle } from 'drizzle-orm/durable-sqlite';
5
+ import * as schema from './auth-schema.js';
6
+ import { resolveCookieDomain } from './cookie-domain.js';
7
+ import { SITE_REGISTRY_DDL, recordSite as recordSiteRow, listSites as listSiteRows, resolveSiteScope as resolveSiteScopeRow, forgetSite as forgetSiteRow, } from './site-registry.js';
8
+ /**
9
+ * The per-tenant IDENTITY Durable Object — one per tenant, running its OWN Better Auth
10
+ * over its OWN SQLite (Drizzle's Durable-Object driver, not a shared D1), and holding the
11
+ * provider-agnostic `sub → principal` directory. A tenant's users/sessions/credentials are
12
+ * isolated in its DO; there is NO per-worker `AUTH_DB` to leak across tenants.
13
+ *
14
+ * It runs in its own isolate (separate process + storage from the vertical's business-data
15
+ * DO), and is one of the vertical's OWN DO classes — a legal sandbox-clean binding (the
16
+ * contract refuses only cross-script / platform bindings).
17
+ *
18
+ * Two roles, either or both used per deployment:
19
+ * - `doAuthProvider` (below) exposes Better Auth here as an `AuthProvider` (the
20
+ * `better-auth-do` config). With an OIDC provider instead, Better Auth stays dormant.
21
+ * - `setPendingOwner` / `resolvePrincipal` are the identity directory — used under EVERY
22
+ * provider, since the subject → principal mapping is provider-independent.
23
+ */
24
+ const SCHEMA_STATEMENTS = [
25
+ `CREATE TABLE IF NOT EXISTS user (
26
+ id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE,
27
+ email_verified INTEGER NOT NULL DEFAULT 0, image TEXT,
28
+ created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
29
+ updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)))`,
30
+ `CREATE TABLE IF NOT EXISTS session (
31
+ id TEXT PRIMARY KEY NOT NULL, expires_at INTEGER NOT NULL, token TEXT NOT NULL UNIQUE,
32
+ created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
33
+ updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT,
34
+ user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE)`,
35
+ `CREATE INDEX IF NOT EXISTS session_userId_idx ON session (user_id)`,
36
+ `CREATE TABLE IF NOT EXISTS account (
37
+ id TEXT PRIMARY KEY NOT NULL, account_id TEXT NOT NULL, provider_id TEXT NOT NULL,
38
+ user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
39
+ access_token TEXT, refresh_token TEXT, id_token TEXT,
40
+ access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT,
41
+ created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
42
+ updated_at INTEGER NOT NULL)`,
43
+ `CREATE INDEX IF NOT EXISTS account_userId_idx ON account (user_id)`,
44
+ `CREATE TABLE IF NOT EXISTS verification (
45
+ id TEXT PRIMARY KEY NOT NULL, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at INTEGER NOT NULL,
46
+ created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
47
+ updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)))`,
48
+ `CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification (identifier)`,
49
+ // The PROVIDER-AGNOSTIC identity directory: a verified subject (`sub`) → the Substrat
50
+ // PrincipalId it maps to, per scope (K-22 — the same login is a different principal in
51
+ // each scope). Independent of WHICH provider verified the subject.
52
+ `CREATE TABLE IF NOT EXISTS identity (scope_id TEXT NOT NULL, sub TEXT NOT NULL, principal TEXT NOT NULL, PRIMARY KEY (scope_id, sub))`,
53
+ // The owner seat waiting to be claimed: set at provision, consumed by the first login.
54
+ `CREATE TABLE IF NOT EXISTS pending_owner (scope_id TEXT PRIMARY KEY, principal TEXT NOT NULL)`,
55
+ // The DURABLE owner of record: also set at provision, but NEVER consumed (#332). `pending_owner`
56
+ // is gone the moment the owner first signs in, so it can't answer "who owns this scope" after
57
+ // that. This can — it's the vertical's own memory of the owner, in the per-tenant IdentityDO
58
+ // (a different DO from the scope's data DO), so it survives a scope-DO storage wipe. A builder-
59
+ // triggered reconcile reads it to re-establish the owner's role grant without the platform
60
+ // secret and without the CP having to persist an owner it doesn't otherwise track.
61
+ `CREATE TABLE IF NOT EXISTS owner_of_record (scope_id TEXT PRIMARY KEY, principal TEXT NOT NULL)`,
62
+ // Outstanding member invites (the post-setup join path). Each is a pre-minted principal +
63
+ // role the admin already granted at scope level, waiting for a login to claim it by token.
64
+ // Only the token's HASH is stored — the token itself lives in the accept link, never here.
65
+ `CREATE TABLE IF NOT EXISTS invite (
66
+ token_hash TEXT PRIMARY KEY, scope_id TEXT NOT NULL, principal TEXT NOT NULL,
67
+ role_key TEXT NOT NULL, email TEXT, claimed INTEGER NOT NULL DEFAULT 0,
68
+ created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)))`,
69
+ `CREATE INDEX IF NOT EXISTS invite_by_scope ON invite (scope_id)`,
70
+ // This DO's own config — notably its session-signing secret, generated here per tenant.
71
+ `CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
72
+ // PER-SCOPE instance config, delivered by the platform via /internal/configure
73
+ // (vertical-auth-detach.md §2.2) — notably the scope's `substrat:auth` issuer choice.
74
+ // Lives here because this DO is the vertical's per-tenant harness store and the scope's
75
+ // own data DO exposes no harness-writable surface.
76
+ `CREATE TABLE IF NOT EXISTS scope_config (scope_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope_id, key))`,
77
+ // The tenant's SITES (K-26 multi-scope) — each site IS a scope. Recorded here at provision so
78
+ // the vertical can enumerate its own sites CP-lessly: the control-plane directory is the
79
+ // platform's list, this is the vertical's own, in the per-tenant IdentityDO (survives a
80
+ // scope-DO wipe). Powers the in-app switcher + the worker's slug → scope resolution. The
81
+ // table/queries live in `site-registry.ts` so they are unit-testable without a DO.
82
+ ...SITE_REGISTRY_DDL,
83
+ ];
84
+ export class IdentityDO extends DurableObject {
85
+ /** This tenant's Better Auth signing secret — generated in this DO, never shared, never a worker binding. */
86
+ authSecret;
87
+ constructor(ctx, env) {
88
+ super(ctx, env);
89
+ ctx.blockConcurrencyWhile(async () => {
90
+ for (const stmt of SCHEMA_STATEMENTS)
91
+ ctx.storage.sql.exec(stmt);
92
+ // Load-or-generate this tenant's OWN signing secret. Each IdentityDO mints its own on
93
+ // first init and persists it here, so the secret is per-tenant, never leaves the DO,
94
+ // and needs no `wrangler secret put` (which would be one value shared across every
95
+ // tenant on the deployed script — the multi-tenant hazard we're avoiding).
96
+ const row = [...ctx.storage.sql.exec("SELECT value FROM config WHERE key = 'auth_secret'")][0];
97
+ if (row) {
98
+ this.authSecret = row.value;
99
+ }
100
+ else {
101
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
102
+ this.authSecret = btoa(String.fromCharCode(...bytes));
103
+ ctx.storage.sql.exec("INSERT INTO config (key, value) VALUES ('auth_secret', ?)", this.authSecret);
104
+ }
105
+ });
106
+ }
107
+ /**
108
+ * Upsert per-scope config delivered by the platform (vertical-auth-detach.md §2.2).
109
+ * Key-by-key, never a replace, so partial deliveries compose; idempotent so the
110
+ * reconciliation sweep can re-run it.
111
+ */
112
+ async setScopeConfig(scopeId, entries) {
113
+ for (const { key, value } of entries) {
114
+ this.ctx.storage.sql.exec('INSERT OR REPLACE INTO scope_config (scope_id, key, value) VALUES (?, ?, ?)', scopeId, key, value);
115
+ }
116
+ }
117
+ /**
118
+ * The config DELIVERED to one scope (dashboard Env tab → `/internal/configure` →
119
+ * `setScopeConfig`), as a plain map — the `delivered` argument of the contracts helper
120
+ * `resolveScopedEnvSpec(spec, env, delivered)`. This is the blessed read for a
121
+ * vertical's ORDINARY env-spec keys (issue #398): overlay these values on worker env,
122
+ * per-scope wins. `authWiring` below is the auth-specific composite (config + session
123
+ * secret in one hop); use that when you also need the secret, this when you don't.
124
+ */
125
+ async getScopeConfig(scopeId) {
126
+ const config = {};
127
+ for (const row of this.ctx.storage.sql.exec('SELECT key, value FROM scope_config WHERE scope_id = ?', scopeId)) {
128
+ config[row.key] = row.value;
129
+ }
130
+ return config;
131
+ }
132
+ /**
133
+ * Everything the worker needs to build the scope's `AuthProvider`, in ONE round-trip:
134
+ * the scope's delivered config (notably `substrat:auth`) plus this tenant's
135
+ * session-signing secret (minted here on first use, never a worker binding — the same
136
+ * per-tenant-secret move as `auth_secret`). One method rather than two keeps the
137
+ * per-request cost at a single DO hop.
138
+ */
139
+ async authWiring(scopeId) {
140
+ const config = await this.getScopeConfig(scopeId);
141
+ let secret = [...this.ctx.storage.sql.exec("SELECT value FROM config WHERE key = 'session_secret'")][0]?.value;
142
+ if (!secret) {
143
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
144
+ secret = btoa(String.fromCharCode(...bytes));
145
+ this.ctx.storage.sql.exec("INSERT INTO config (key, value) VALUES ('session_secret', ?)", secret);
146
+ }
147
+ return { config, sessionSecret: secret };
148
+ }
149
+ /**
150
+ * Record a SITE (scope) at provision — the vertical's own memory of its sites (K-26
151
+ * multi-scope), used to enumerate and switch between them without reaching the control-plane
152
+ * directory. Idempotent: a re-provision (K-31) updates the slug/name but keeps `created_at`,
153
+ * so the switcher's ordering is stable.
154
+ */
155
+ async recordSite(scopeId, slug, name) {
156
+ recordSiteRow(this.registrySql, scopeId, slug, name);
157
+ }
158
+ /** Drop a site from the registry (the vertical's half of archiving it). Idempotent. */
159
+ async forgetSite(scopeId) {
160
+ forgetSiteRow(this.registrySql, scopeId);
161
+ }
162
+ /** The tenant's sites, oldest first — the in-app site switcher's list. */
163
+ async listSites() {
164
+ return listSiteRows(this.registrySql);
165
+ }
166
+ /**
167
+ * Resolve a site SLUG to its scope id within this tenant — how the worker turns the app's
168
+ * `x-site` selection into the scope to open. Null ⇒ no such site (the caller falls back to the
169
+ * routed home scope). Tenant-scoped by construction: this DO is per-tenant, so a slug can only
170
+ * ever resolve to a scope of THIS tenant.
171
+ */
172
+ async resolveSiteScope(slug) {
173
+ return resolveSiteScopeRow(this.registrySql, slug);
174
+ }
175
+ /** The DO's SQLite as the registry's minimal `exec` seam (see site-registry.ts). */
176
+ get registrySql() {
177
+ return this.ctx.storage.sql;
178
+ }
179
+ /**
180
+ * Record the owner seat at provision. Writes BOTH the transient `pending_owner` (claimed +
181
+ * consumed by the first login) and the durable `owner_of_record` (#332, never consumed) in one
182
+ * call, so the vertical always retains who the owner is even after the seat has been claimed.
183
+ * Idempotent — the reconciliation sweep re-runs it.
184
+ */
185
+ async setPendingOwner(scopeId, principal) {
186
+ this.ctx.storage.sql.exec('INSERT OR REPLACE INTO pending_owner (scope_id, principal) VALUES (?, ?)', scopeId, principal);
187
+ this.ctx.storage.sql.exec('INSERT OR REPLACE INTO owner_of_record (scope_id, principal) VALUES (?, ?)', scopeId, principal);
188
+ }
189
+ /**
190
+ * The scope's durable owner of record, or null if this scope was never provisioned through this
191
+ * DO (a legacy scope predating #332, or a slug typo). Read by the reconcile path to re-grant the
192
+ * owner their role after a scope-DO wipe left the scope enforcing against an empty tuple table.
193
+ */
194
+ async getOwnerOfRecord(scopeId) {
195
+ const row = [...this.ctx.storage.sql.exec('SELECT principal FROM owner_of_record WHERE scope_id = ?', scopeId)][0];
196
+ return row?.principal ?? null;
197
+ }
198
+ /**
199
+ * Is this scope awaiting first-run setup? True while its owner seat is unclaimed — the
200
+ * instance is provisioned but no admin has signed in yet. The worker uses this to show a
201
+ * "create the admin account" setup screen instead of a plain sign-in, and to keep open
202
+ * sign-up allowed only during this window (after the owner claims, sign-up is invite-only).
203
+ */
204
+ async needsSetup(scopeId) {
205
+ const pending = [...this.ctx.storage.sql.exec('SELECT 1 FROM pending_owner WHERE scope_id = ?', scopeId)][0];
206
+ return pending !== undefined;
207
+ }
208
+ /**
209
+ * Map a verified subject to a PrincipalId in this scope. If already bound, return it. If
210
+ * not, and the scope's owner seat is unclaimed, CLAIM it: bind this subject to the owner
211
+ * principal and consume the pending seat. Otherwise null — a valid login with no seat has
212
+ * no access. Provider-agnostic: the subject may come from Better Auth or an OIDC issuer.
213
+ */
214
+ async resolvePrincipal(scopeId, sub) {
215
+ const bound = [...this.ctx.storage.sql.exec('SELECT principal FROM identity WHERE scope_id = ? AND sub = ?', scopeId, sub)][0];
216
+ if (bound)
217
+ return bound.principal;
218
+ const pending = [...this.ctx.storage.sql.exec('SELECT principal FROM pending_owner WHERE scope_id = ?', scopeId)][0];
219
+ if (!pending)
220
+ return null;
221
+ this.ctx.storage.sql.exec('INSERT OR REPLACE INTO identity (scope_id, sub, principal) VALUES (?, ?, ?)', scopeId, sub, pending.principal);
222
+ this.ctx.storage.sql.exec('DELETE FROM pending_owner WHERE scope_id = ?', scopeId);
223
+ return pending.principal;
224
+ }
225
+ /**
226
+ * Record a member invite: a pre-minted `principal` + `roleKey` the caller has already
227
+ * granted at scope level, claimable by whoever presents the token whose hash is `tokenHash`.
228
+ * The plaintext token never reaches the DO — only its hash, so a DB read can't mint access.
229
+ */
230
+ async createInvite(scopeId, principal, roleKey, email, tokenHash) {
231
+ this.ctx.storage.sql.exec('INSERT INTO invite (token_hash, scope_id, principal, role_key, email) VALUES (?, ?, ?, ?, ?)', tokenHash, scopeId, principal, roleKey, email);
232
+ }
233
+ /** The scope's outstanding (unclaimed) invites — for the admin's pending-invites list. No token. */
234
+ async listInvites(scopeId) {
235
+ return [...this.ctx.storage.sql.exec('SELECT principal, role_key, email, created_at FROM invite WHERE scope_id = ? AND claimed = 0 ORDER BY created_at DESC', scopeId)].map((r) => ({ principal: r.principal, roleKey: r.role_key, email: r.email ?? null, createdAt: r.created_at }));
236
+ }
237
+ /** Is there an unclaimed invite for this token hash? (the sign-up gate consults this post-setup). */
238
+ async inviteExists(scopeId, tokenHash) {
239
+ const r = [...this.ctx.storage.sql.exec('SELECT 1 FROM invite WHERE scope_id = ? AND token_hash = ? AND claimed = 0', scopeId, tokenHash)][0];
240
+ return r !== undefined;
241
+ }
242
+ /** Withdraw an unclaimed invite by its (pre-minted) principal — the id the admin sees. */
243
+ async revokeInvite(scopeId, principal) {
244
+ this.ctx.storage.sql.exec('DELETE FROM invite WHERE scope_id = ? AND principal = ? AND claimed = 0', scopeId, principal);
245
+ }
246
+ /**
247
+ * Claim an invite: bind this verified subject to the invite's pre-minted principal and
248
+ * consume the invite. Returns the principal, or null if no unclaimed invite matches the
249
+ * token hash. The role was granted at scope level when the invite was created, so the
250
+ * bound principal resolves its permissions immediately.
251
+ */
252
+ async claimInvite(scopeId, sub, tokenHash) {
253
+ const inv = [...this.ctx.storage.sql.exec('SELECT principal FROM invite WHERE scope_id = ? AND token_hash = ? AND claimed = 0', scopeId, tokenHash)][0];
254
+ if (!inv)
255
+ return null;
256
+ this.ctx.storage.sql.exec('INSERT OR REPLACE INTO identity (scope_id, sub, principal) VALUES (?, ?, ?)', scopeId, sub, inv.principal);
257
+ this.ctx.storage.sql.exec('UPDATE invite SET claimed = 1 WHERE scope_id = ? AND token_hash = ?', scopeId, tokenHash);
258
+ return inv.principal;
259
+ }
260
+ /** A Better Auth instance over THIS DO's SQLite, trusting the caller's origin. */
261
+ auth(origin, cookieDomain) {
262
+ const db = drizzle(this.ctx.storage, { schema });
263
+ return betterAuth({
264
+ database: drizzleAdapter(db, { provider: 'sqlite', schema }),
265
+ emailAndPassword: { enabled: true, autoSignIn: true, minPasswordLength: 8 },
266
+ // Per-tenant, generated + persisted in THIS DO (see the constructor) — never shared.
267
+ secret: this.authSecret,
268
+ baseURL: origin,
269
+ trustedOrigins: [origin],
270
+ // Shared login across a scope's surfaces (crm.…, eka.… — one parent domain): the
271
+ // session cookie rides `Domain=…` instead of host-only. Verified everywhere already
272
+ // (one per-tenant secret, this DO); the attribute is the only thing that was missing.
273
+ ...(cookieDomain
274
+ ? { advanced: { crossSubDomainCookies: { enabled: true, domain: cookieDomain } } }
275
+ : {}),
276
+ });
277
+ }
278
+ /**
279
+ * The DO's HTTP surface (used only when Better Auth is the chosen provider). `/__session`
280
+ * resolves the request to an `AuthSubject`; everything else is a Better Auth request. The
281
+ * worker forwards requests here; Better Auth never runs in the worker.
282
+ *
283
+ * `x-substrat-cookie-domain` is the worker relaying the scope's delivered `substrat:auth`
284
+ * cookie-domain choice (the DO can't look it up itself — this fetch carries no scope id).
285
+ * The stub is reachable only from the worker, so the header is as trusted as the config;
286
+ * it is still re-validated against the request host before it touches a Set-Cookie.
287
+ */
288
+ async fetch(request) {
289
+ const url = new URL(request.url);
290
+ const cookieDomain = resolveCookieDomain(request.headers.get('x-substrat-cookie-domain') ?? undefined, url.hostname);
291
+ const auth = this.auth(url.origin, cookieDomain);
292
+ if (url.pathname === '/__session') {
293
+ const session = await auth.api.getSession({ headers: request.headers });
294
+ const subject = session?.user
295
+ ? { sub: session.user.id, email: session.user.email ?? null, name: session.user.name ?? null }
296
+ : null;
297
+ return Response.json(subject);
298
+ }
299
+ return auth.handler(request);
300
+ }
301
+ }
302
+ /**
303
+ * The `AuthProvider` backed by a tenant's identity-DO stub (the `better-auth-do` config).
304
+ * `handle` forwards the raw request; `resolve` asks the DO's `/__session` probe, carrying
305
+ * the request's cookies. The worker holds only this — never Better Auth itself.
306
+ *
307
+ * `cookieDomain` (the scope's delivered `substrat:auth` choice) rides to the DO as a
308
+ * header because the worker holds the config and the DO holds Better Auth — see fetch().
309
+ */
310
+ export function doAuthProvider(stub, origin, opts) {
311
+ const forward = (request) => {
312
+ if (!opts?.cookieDomain)
313
+ return request;
314
+ const relayed = new Request(request);
315
+ relayed.headers.set('x-substrat-cookie-domain', opts.cookieDomain);
316
+ return relayed;
317
+ };
318
+ return {
319
+ handle: (request) => stub.fetch(forward(request)),
320
+ async resolve(headers) {
321
+ const res = await stub.fetch(new Request(`${origin}/__session`, { headers }));
322
+ return (await res.json());
323
+ },
324
+ };
325
+ }
326
+ //# sourceMappingURL=identity-do.js.map