@rexezuge/identity 0.0.0-stage → 1.0.1

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,151 @@
1
+ import { BadRequestError } from '@rexezuge/errors';
2
+ import { isValidEmailFormat } from '@rexezuge/shared';
3
+ import { TimestampUtil } from '@rexezuge/shared';
4
+ import { registerAccount, resolveAccount } from './accountLookup';
5
+ import { newAnchor, summarize } from './ResolvedAccount';
6
+ import { UserDAO } from './UserDAO';
7
+ import { UserEmailDAO } from './UserEmailDAO';
8
+ /**
9
+ * The account behind an address, plus the address-change operations.
10
+ *
11
+ * Converged from `backend-services/src/identity/UserIdentityService.ts`
12
+ * (identical in Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter; AWS and
13
+ * Mail-Meow keep the resolve core). Canonical names merge the two spellings the
14
+ * repos used — `resolveUserById` (Durable-DAV family) and
15
+ * `resolveAccountById` (AWS) are one method, `resolveAccountById`.
16
+ *
17
+ * The instance is one per request scope: the ownership and permission paths
18
+ * resolve the same caller several times each, and a per-instance address memo
19
+ * costs one query per request rather than one per comparison. Bind it once in
20
+ * the composition root and share it — constructing per consumer is the bug the
21
+ * memo exists to prevent.
22
+ *
23
+ * Resolution itself lives in `accountLookup` (free functions, shared with any
24
+ * account bootstrap); this class adds memoization and the change operations.
25
+ */
26
+ export class UserIdentityService {
27
+ deps;
28
+ byEmail = new Map();
29
+ constructor(env, deps = {}) {
30
+ this.deps = {
31
+ userDAO: () => Promise.resolve(new UserDAO(env.DB)),
32
+ userEmailDAO: () => Promise.resolve(new UserEmailDAO(env.DB)),
33
+ ...deps,
34
+ };
35
+ }
36
+ /**
37
+ * Resolve a sign-in address to its account, or null when unknown.
38
+ *
39
+ * Only a verified address resolves. A revoked address (changed away from) is
40
+ * retained so pre-change rows stay attributable, but it must never
41
+ * authenticate, otherwise a reassigned address would inherit the previous
42
+ * holder's account.
43
+ */
44
+ async resolveAccount(email) {
45
+ const key = email.trim().toLowerCase();
46
+ if (!isValidEmailFormat(key))
47
+ return null;
48
+ if (this.byEmail.has(key))
49
+ return this.byEmail.get(key) ?? null;
50
+ const resolved = await resolveAccount(this.deps, key);
51
+ this.byEmail.set(key, resolved);
52
+ return resolved;
53
+ }
54
+ /** Account id for a sign-in address, or null when unknown. */
55
+ async resolveAccountId(email) {
56
+ const account = await this.resolveAccount(email);
57
+ return account?.id ?? null;
58
+ }
59
+ /**
60
+ * Account behind a stable key. The inverse direction, for a caller that
61
+ * already holds one (a resource owner) and needs the current address.
62
+ */
63
+ async resolveAccountById(userId) {
64
+ const userDAO = await this.deps.userDAO();
65
+ const row = await userDAO.getById(userId);
66
+ return summarize(row);
67
+ }
68
+ /**
69
+ * Resolve-or-create, for a login path that may see a first-time user.
70
+ *
71
+ * Returns null when the address exists but cannot be resolved (a revoked
72
+ * address) — a caller that needs "guaranteed account" must distinguish that
73
+ * from "created now" through its own route, because silently registering on a
74
+ * revoked address would hand the previous holder's history to the new one.
75
+ */
76
+ async resolveOrRegister(email, now = TimestampUtil.getCurrentUnixTimestampInSeconds()) {
77
+ const existing = await this.resolveAccount(email);
78
+ if (existing)
79
+ return existing;
80
+ const key = email.trim().toLowerCase();
81
+ // The anchor is the address itself whenever it is free. When it is already
82
+ // held as another account's anchor — its previous holder moved off it — the
83
+ // opaque-anchor retry is what lets a released address be used again.
84
+ const account = (await registerAccount(this.deps, key, key, now)) ?? (await registerAccount(this.deps, key, newAnchor(), now));
85
+ this.byEmail.set(key, account);
86
+ return account;
87
+ }
88
+ /**
89
+ * Every address known for an account, verified ones first.
90
+ */
91
+ async listAddresses(userId) {
92
+ const emailDAO = await this.deps.userEmailDAO();
93
+ const rows = await emailDAO.listByUserId(userId);
94
+ return rows.map((row) => ({ email: row.email, isVerified: row.is_verified === 1 }));
95
+ }
96
+ /**
97
+ * Point an account at a new sign-in address.
98
+ *
99
+ * The account id, the frozen anchor, and every id-keyed row are untouched:
100
+ * only which address authenticates the account moves. The previous address is
101
+ * revoked rather than deleted, so rows written before the change still resolve
102
+ * to this account, and it is released for a later legitimate holder.
103
+ *
104
+ * Rejects an address already verified for another account. That check is the
105
+ * whole reason this is not simply an `UPDATE`: an unverified change would let
106
+ * anyone claim an address and inherit its history.
107
+ *
108
+ * Order is load-bearing, and it is the one the ops CLI (`change-email.ts`)
109
+ * already gets right: claim first, move `current_email` second, revoke third.
110
+ * Inverting it opens a window in which nothing authenticates if a later step
111
+ * fails.
112
+ */
113
+ async setPrimaryEmail(userId, newEmail, now = TimestampUtil.getCurrentUnixTimestampInSeconds()) {
114
+ const email = newEmail.trim().toLowerCase();
115
+ if (!isValidEmailFormat(email))
116
+ throw new BadRequestError('Invalid email address');
117
+ const userDAO = await this.deps.userDAO();
118
+ const emailDAO = await this.deps.userEmailDAO();
119
+ const row = await userDAO.getById(userId);
120
+ if (!row?.id)
121
+ throw new BadRequestError('User not found');
122
+ const current = (row.current_email ?? row.email).toLowerCase();
123
+ if (current === email) {
124
+ return { id: row.id, email, anchorEmail: row.email, username: row.username ?? null };
125
+ }
126
+ const holder = await emailDAO.resolveVerified(email);
127
+ if (holder && holder.user_id !== row.id)
128
+ throw new BadRequestError('Email is already in use');
129
+ await emailDAO.register({ email, userId: row.id, isVerified: true, now });
130
+ await userDAO.setCurrentEmail(row.id, email, now);
131
+ // Revoke every other verified address, so only the new one authenticates.
132
+ await emailDAO.revokeAllVerified(row.id, email);
133
+ this.byEmail.delete(current);
134
+ this.byEmail.set(email, { id: row.id, email, anchorEmail: row.email, username: row.username ?? null });
135
+ return { id: row.id, email, anchorEmail: row.email, username: row.username ?? null };
136
+ }
137
+ /**
138
+ * Ops/migration path: attach an address that has already been proven, without
139
+ * making it the sign-in address.
140
+ */
141
+ async linkVerifiedEmail(userId, email, now = TimestampUtil.getCurrentUnixTimestampInSeconds()) {
142
+ const address = email.trim().toLowerCase();
143
+ if (!isValidEmailFormat(address))
144
+ throw new BadRequestError('Invalid email address');
145
+ const emailDAO = await this.deps.userEmailDAO();
146
+ const outcome = await emailDAO.register({ email: address, userId, isVerified: true, now });
147
+ if (outcome === 'already-claimed')
148
+ throw new BadRequestError('Email is already in use');
149
+ }
150
+ }
151
+ //# sourceMappingURL=UserIdentityService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserIdentityService.js","sourceRoot":"","sources":["../src/UserIdentityService.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,45 @@
1
+ import type { ResolvedAccount } from './ResolvedAccount';
2
+ import { UserDAO } from './UserDAO';
3
+ import { UserEmailDAO } from './UserEmailDAO';
4
+ /**
5
+ * Injected DAO factories. All optional: each defaults to a real DAO over the
6
+ * same D1 binding, so tests override only what they need.
7
+ */
8
+ export interface AccountLookupDeps {
9
+ userDAO: () => Promise<UserDAO>;
10
+ userEmailDAO: () => Promise<UserEmailDAO>;
11
+ }
12
+ /**
13
+ * Address -> account resolution, as free functions so the service and any
14
+ * higher-level account bootstrap share one implementation instead of
15
+ * re-deriving the query.
16
+ *
17
+ * Converged from `backend-services/src/user/accountLookup.ts` (identical in
18
+ * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter).
19
+ */
20
+ export declare function resolveAccount(deps: AccountLookupDeps, email: string): Promise<ResolvedAccount | null>;
21
+ /** Account id for a sign-in address, or null when unknown. */
22
+ export declare function resolveUserId(deps: AccountLookupDeps, email: string): Promise<string | null>;
23
+ /**
24
+ * Insert an account and claim its sign-in address.
25
+ *
26
+ * The anchor is the address itself whenever it is free, which keeps new rows
27
+ * shaped like the pre-identity ones. It falls back to an opaque anchor only when
28
+ * the address is already held as another account's anchor — i.e. its previous
29
+ * holder moved off it — so a released address is never permanently unusable.
30
+ *
31
+ * Returns null when the insert could not produce a *new* account holding this
32
+ * address, which is the caller's signal to retry with a different anchor.
33
+ *
34
+ * The read-back is load-bearing, not defensive. `createUser` is
35
+ * `ON CONFLICT(email) DO NOTHING`, so when the anchor is already taken the
36
+ * insert is silently a no-op and the generated `id` belongs to nobody. Trusting
37
+ * it would (a) re-point the address registry at a phantom account that resolves
38
+ * to nothing, permanently stranding the address, and (b) on the opaque-anchor
39
+ * retry, hand the caller an account whose address the registry refuses to
40
+ * re-point because the phantom row already marked it verified. Re-reading the
41
+ * anchor row and requiring *our* id to be the one there makes the no-op
42
+ * detectable, so the retry gets a genuinely new account.
43
+ */
44
+ export declare function registerAccount(deps: AccountLookupDeps, loginEmail: string, anchor: string, now: number): Promise<ResolvedAccount | null>;
45
+ //# sourceMappingURL=accountLookup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accountLookup.d.ts","sourceRoot":"","sources":["../src/accountLookup.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,YAAY,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;CAC3C;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CA0C5G;AAED,8DAA8D;AAC9D,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAGlG;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,iBAAiB,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAsBjC"}
@@ -0,0 +1,107 @@
1
+ import { isMissingSchemaError } from '@rexezuge/d1';
2
+ import { summarize } from './ResolvedAccount';
3
+ /**
4
+ * Address -> account resolution, as free functions so the service and any
5
+ * higher-level account bootstrap share one implementation instead of
6
+ * re-deriving the query.
7
+ *
8
+ * Converged from `backend-services/src/user/accountLookup.ts` (identical in
9
+ * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter).
10
+ */
11
+ export async function resolveAccount(deps, email) {
12
+ const normalized = email.trim().toLowerCase();
13
+ if (!normalized)
14
+ return null;
15
+ const userDAO = await deps.userDAO();
16
+ // A known address is authoritative: the registry says which account it belongs
17
+ // to, and a *revoked* row (the account moved off this address) must not
18
+ // resolve at all. Falling through to the legacy lookups in that case would let
19
+ // a reassigned address keep authenticating the previous holder's account.
20
+ //
21
+ // Only a *missing registry* falls through, and "missing" means one specific
22
+ // thing: the identity migration has not run. The previous
23
+ // `.catch(() => null)` answered that question for **every** failure, so a
24
+ // transient D1 error on this one statement made the code proceed to the anchor
25
+ // lookup below — `getByEmail`, which is an attribution lookup whose key is a
26
+ // re-registrable string, not an identity. An address whose account had moved
27
+ // off it (so its `user_emails` row is revoked, and correctly refuses to
28
+ // resolve) would then be resolved against the *previous holder's* anchor, and
29
+ // that account's id was installed as the caller's identity: full access to
30
+ // someone else's rows from one failed `SELECT`.
31
+ //
32
+ // So the degradation is narrowed to `isMissingSchemaError`, and everything
33
+ // else rethrows.
34
+ const registered = await deps
35
+ .userEmailDAO()
36
+ .then((dao) => dao.get(normalized))
37
+ .catch((error) => {
38
+ if (isMissingSchemaError(error))
39
+ return null;
40
+ throw error;
41
+ });
42
+ if (registered) {
43
+ return registered.is_verified === 1 ? summarize(await userDAO.getById(registered.user_id)) : null;
44
+ }
45
+ // No registry row at all. Safe to fall through: the identity migration
46
+ // backfilled a verified row for every anchor, so a row-less address is either
47
+ // new or on a pre-identity database, where the address *is* the anchor.
48
+ //
49
+ // No `.catch` on either: a failed `users` read is an outage, and reporting it
50
+ // as "no such account" would turn a D1 blip into a 404 and a spurious
51
+ // re-registration attempt.
52
+ const byCurrent = await userDAO.getByCurrentEmail(normalized);
53
+ const legacy = byCurrent ?? (await userDAO.getByEmail(normalized));
54
+ return summarize(legacy);
55
+ }
56
+ /** Account id for a sign-in address, or null when unknown. */
57
+ export async function resolveUserId(deps, email) {
58
+ const account = await resolveAccount(deps, email);
59
+ return account?.id ?? null;
60
+ }
61
+ /**
62
+ * Insert an account and claim its sign-in address.
63
+ *
64
+ * The anchor is the address itself whenever it is free, which keeps new rows
65
+ * shaped like the pre-identity ones. It falls back to an opaque anchor only when
66
+ * the address is already held as another account's anchor — i.e. its previous
67
+ * holder moved off it — so a released address is never permanently unusable.
68
+ *
69
+ * Returns null when the insert could not produce a *new* account holding this
70
+ * address, which is the caller's signal to retry with a different anchor.
71
+ *
72
+ * The read-back is load-bearing, not defensive. `createUser` is
73
+ * `ON CONFLICT(email) DO NOTHING`, so when the anchor is already taken the
74
+ * insert is silently a no-op and the generated `id` belongs to nobody. Trusting
75
+ * it would (a) re-point the address registry at a phantom account that resolves
76
+ * to nothing, permanently stranding the address, and (b) on the opaque-anchor
77
+ * retry, hand the caller an account whose address the registry refuses to
78
+ * re-point because the phantom row already marked it verified. Re-reading the
79
+ * anchor row and requiring *our* id to be the one there makes the no-op
80
+ * detectable, so the retry gets a genuinely new account.
81
+ */
82
+ export async function registerAccount(deps, loginEmail, anchor, now) {
83
+ const userDAO = await deps.userDAO();
84
+ const created = await userDAO.createUser({ anchor, loginEmail, now });
85
+ // `null` is the detected no-op: the anchor was already held. The caller's
86
+ // signal to retry with an opaque anchor — see `newAnchor`.
87
+ if (created === null)
88
+ return null;
89
+ // Claim the sign-in address *before* resolving, otherwise the fresh account is
90
+ // invisible to the registry and registration looks like a failure. An address
91
+ // already verified for another account is left alone by `register`, and the
92
+ // resolve below then reports that account instead.
93
+ await deps
94
+ .userEmailDAO()
95
+ .then((dao) => dao.register({ email: loginEmail, userId: created, isVerified: true, now }))
96
+ .catch((error) => {
97
+ // Pre-identity registry only, per the same rule `resolveAccount` follows: a
98
+ // missing table is tolerated, a failed write is not. Swallowing the second
99
+ // would return an account the address registry has never heard of, which
100
+ // resolves to nothing on the next login.
101
+ if (isMissingSchemaError(error))
102
+ return;
103
+ throw error;
104
+ });
105
+ return resolveAccount(deps, loginEmail);
106
+ }
107
+ //# sourceMappingURL=accountLookup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accountLookup.js","sourceRoot":"","sources":["../src/accountLookup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAc9C;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAuB,EAAE,KAAa;IACzE,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC9C,IAAI,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IAC7B,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACrC,+EAA+E;IAC/E,wEAAwE;IACxE,+EAA+E;IAC/E,0EAA0E;IAC1E,EAAE;IACF,4EAA4E;IAC5E,0DAA0D;IAC1D,0EAA0E;IAC1E,+EAA+E;IAC/E,6EAA6E;IAC7E,6EAA6E;IAC7E,wEAAwE;IACxE,8EAA8E;IAC9E,2EAA2E;IAC3E,gDAAgD;IAChD,EAAE;IACF,2EAA2E;IAC3E,iBAAiB;IACjB,MAAM,UAAU,GAAG,MAAM,IAAI;SAC1B,YAAY,EAAE;SACd,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;SAClC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QACxB,IAAI,oBAAoB,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAC7C,MAAM,KAAK,CAAC;IACd,CAAC,CAAC,CAAC;IACL,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,UAAU,CAAC,WAAW,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACpG,CAAC;IACD,uEAAuE;IACvE,8EAA8E;IAC9E,wEAAwE;IACxE,EAAE;IACF,8EAA8E;IAC9E,sEAAsE;IACtE,2BAA2B;IAC3B,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,UAAU,CAAC,CAAC;IAC9D,MAAM,MAAM,GAAG,SAAS,IAAI,CAAC,MAAM,OAAO,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;IACnE,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC;AAC3B,CAAC;AAED,8DAA8D;AAC9D,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAuB,EAAE,KAAa;IACxE,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,OAAO,OAAO,EAAE,EAAE,IAAI,IAAI,CAAC;AAC7B,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,IAAuB,EACvB,UAAkB,EAClB,MAAc,EACd,GAAW;IAEX,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACrC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC,CAAC;IACtE,0EAA0E;IAC1E,2DAA2D;IAC3D,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAClC,+EAA+E;IAC/E,8EAA8E;IAC9E,4EAA4E;IAC5E,mDAAmD;IACnD,MAAM,IAAI;SACP,YAAY,EAAE;SACd,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;SAC1F,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QACxB,4EAA4E;QAC5E,2EAA2E;QAC3E,yEAAyE;QACzE,yCAAyC;QACzC,IAAI,oBAAoB,CAAC,KAAK,CAAC;YAAE,OAAO;QACxC,MAAM,KAAK,CAAC;IACd,CAAC,CAAC,CAAC;IACL,OAAO,cAAc,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;AAC1C,CAAC"}
@@ -0,0 +1,33 @@
1
+ import type { D1Queryable } from '@rexezuge/d1';
2
+ /**
3
+ * The identity migrations this package ships, by filename. `IDENTITY_MIGRATIONS`
4
+ * in `migrations.ts` carries the same pair with the reasoning; this map is the
5
+ * executable form.
6
+ */
7
+ declare const IDENTITY_MIGRATION_SQL: {
8
+ readonly 'users.sql': "\n-- The account. The anchor (email) is immutable; current_email is the login.\nCREATE TABLE IF NOT EXISTS users (\n id TEXT NOT NULL PRIMARY KEY,\n email TEXT NOT NULL UNIQUE,\n current_email TEXT NOT NULL UNIQUE,\n username TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_id ON users(id);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_current_email ON users(current_email);\n\n-- The address registry: which account an address authenticates.\nCREATE TABLE IF NOT EXISTS user_emails (\n email TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n is_verified INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_emails_user ON user_emails(user_id);\n";
9
+ readonly 'upgrade-user-identity.sql': "\n-- Phase 1: stable account key.\n-- SQLite cannot add a PRIMARY KEY column, so the id is a plain column with a\n-- unique index. A unique index is a valid foreign key parent, which is all the\n-- user_id references need.\nALTER TABLE users ADD COLUMN id TEXT;\n\nUPDATE users SET id = 'usr_' || lower(hex(randomblob(16))) WHERE id IS NULL;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_id ON users(id);\n\n-- Phase 2: mutable sign-in address.\n-- 'email' stays as the frozen anchor; 'current_email' is what the account signs\n-- in with and what the API reports. Uniqueness is enforced here, so an address\n-- can never be claimed by two accounts.\nALTER TABLE users ADD COLUMN current_email TEXT;\n\nUPDATE users SET current_email = lower(email) WHERE current_email IS NULL;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_current_email ON users(current_email);\n\n-- Phase 3: address registry.\n-- Login resolution consults is_verified = 1 only. Backfilled from the frozen\n-- anchor address of every existing account, lowercased so a legacy mixed-case\n-- row still yields exactly one login identity.\nCREATE TABLE IF NOT EXISTS user_emails (\n email TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n is_verified INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_emails_user ON user_emails(user_id);\n\nINSERT OR IGNORE INTO user_emails (email, user_id, is_verified, created_at)\nSELECT lower(email), id, 1, created_at FROM users;\n";
10
+ };
11
+ type IdentityMigrationName = keyof typeof IDENTITY_MIGRATION_SQL;
12
+ /** The full fresh-install order. */
13
+ declare const FRESH_MIGRATIONS: readonly IdentityMigrationName[];
14
+ /** A legacy database applies the upgrade after the fresh schema is in place. */
15
+ declare const UPGRADE_MIGRATIONS: readonly IdentityMigrationName[];
16
+ /**
17
+ * Apply the identity migrations to a D1 binding.
18
+ *
19
+ * Split and executed through `@rexezuge/d1` — the same statement splitter the
20
+ * test helpers use — so a kit migration and an integration suite can never
21
+ * disagree about where one statement ends. One `batch()` per file, matching
22
+ * D1's own per-file apply, so a failure names its file.
23
+ *
24
+ * `upgrade: false` (the default) applies only the fresh-install schema. Pass
25
+ * `upgrade: true` for a database whose `users` table predates the identity
26
+ * columns; the upgrade file is rerunnable, so applying both is safe.
27
+ */
28
+ declare function applyIdentityMigrations(db: D1Queryable, options?: {
29
+ upgrade?: boolean;
30
+ }): Promise<void>;
31
+ export { applyIdentityMigrations, IDENTITY_MIGRATION_SQL, FRESH_MIGRATIONS, UPGRADE_MIGRATIONS };
32
+ export type { IdentityMigrationName };
33
+ //# sourceMappingURL=applyMigrations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applyMigrations.d.ts","sourceRoot":"","sources":["../src/applyMigrations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD;;;;GAIG;AACH,QAAA,MAAM,sBAAsB;;;CAGlB,CAAC;AAEX,KAAK,qBAAqB,GAAG,MAAM,OAAO,sBAAsB,CAAC;AAEjE,oCAAoC;AACpC,QAAA,MAAM,gBAAgB,EAAE,SAAS,qBAAqB,EAAkB,CAAC;AAEzE,gFAAgF;AAChF,QAAA,MAAM,kBAAkB,EAAE,SAAS,qBAAqB,EAA+C,CAAC;AAExG;;;;;;;;;;;GAWG;AACH,iBAAe,uBAAuB,CAAC,EAAE,EAAE,WAAW,EAAE,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAa1G;AAED,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,CAAC;AACjG,YAAY,EAAE,qBAAqB,EAAE,CAAC"}
@@ -0,0 +1,44 @@
1
+ import { executableStatements } from '@rexezuge/d1';
2
+ import { UPGRADE_USER_IDENTITY, USERS_SCHEMA } from './migrations';
3
+ /**
4
+ * The identity migrations this package ships, by filename. `IDENTITY_MIGRATIONS`
5
+ * in `migrations.ts` carries the same pair with the reasoning; this map is the
6
+ * executable form.
7
+ */
8
+ const IDENTITY_MIGRATION_SQL = {
9
+ 'users.sql': USERS_SCHEMA,
10
+ 'upgrade-user-identity.sql': UPGRADE_USER_IDENTITY,
11
+ };
12
+ /** The full fresh-install order. */
13
+ const FRESH_MIGRATIONS = ['users.sql'];
14
+ /** A legacy database applies the upgrade after the fresh schema is in place. */
15
+ const UPGRADE_MIGRATIONS = ['users.sql', 'upgrade-user-identity.sql'];
16
+ /**
17
+ * Apply the identity migrations to a D1 binding.
18
+ *
19
+ * Split and executed through `@rexezuge/d1` — the same statement splitter the
20
+ * test helpers use — so a kit migration and an integration suite can never
21
+ * disagree about where one statement ends. One `batch()` per file, matching
22
+ * D1's own per-file apply, so a failure names its file.
23
+ *
24
+ * `upgrade: false` (the default) applies only the fresh-install schema. Pass
25
+ * `upgrade: true` for a database whose `users` table predates the identity
26
+ * columns; the upgrade file is rerunnable, so applying both is safe.
27
+ */
28
+ async function applyIdentityMigrations(db, options = {}) {
29
+ const names = options.upgrade === true ? UPGRADE_MIGRATIONS : FRESH_MIGRATIONS;
30
+ for (const name of names) {
31
+ const statements = executableStatements(IDENTITY_MIGRATION_SQL[name]);
32
+ if (statements.length === 0)
33
+ continue;
34
+ if (db.batch) {
35
+ await db.batch(statements.map((sql) => db.prepare(sql)));
36
+ continue;
37
+ }
38
+ for (const sql of statements) {
39
+ await db.prepare(sql).run();
40
+ }
41
+ }
42
+ }
43
+ export { applyIdentityMigrations, IDENTITY_MIGRATION_SQL, FRESH_MIGRATIONS, UPGRADE_MIGRATIONS };
44
+ //# sourceMappingURL=applyMigrations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applyMigrations.js","sourceRoot":"","sources":["../src/applyMigrations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAEpD,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEnE;;;;GAIG;AACH,MAAM,sBAAsB,GAAG;IAC7B,WAAW,EAAE,YAAY;IACzB,2BAA2B,EAAE,qBAAqB;CAC1C,CAAC;AAIX,oCAAoC;AACpC,MAAM,gBAAgB,GAAqC,CAAC,WAAW,CAAC,CAAC;AAEzE,gFAAgF;AAChF,MAAM,kBAAkB,GAAqC,CAAC,WAAW,EAAE,2BAA2B,CAAC,CAAC;AAExG;;;;;;;;;;;GAWG;AACH,KAAK,UAAU,uBAAuB,CAAC,EAAe,EAAE,UAAiC,EAAE;IACzF,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,gBAAgB,CAAC;IAC/E,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,UAAU,GAAG,oBAAoB,CAAC,sBAAsB,CAAC,IAAI,CAAC,CAAC,CAAC;QACtE,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACtC,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC;YACb,MAAM,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACzD,SAAS;QACX,CAAC;QACD,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;YAC7B,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;QAC9B,CAAC;IACH,CAAC;AACH,CAAC;AAED,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,CAAC"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@rexezuge/identity` — the account layer: the address is not the account.
3
+ *
4
+ * Converged from the seven repos that authenticate users (all but
5
+ * ChordDHT-Tracker, whose callers are certificates). Durable-DAV's invariants
6
+ * are the canonical ones because they are the strictest: the frozen anchor,
7
+ * the verified-only registry, and the fail-closed resolution order.
8
+ */
9
+ export { registerAccount, resolveAccount, resolveUserId } from './accountLookup';
10
+ export type { AccountLookupDeps } from './accountLookup';
11
+ export { applyIdentityMigrations, IDENTITY_MIGRATION_SQL, FRESH_MIGRATIONS, UPGRADE_MIGRATIONS } from './applyMigrations';
12
+ export type { IdentityMigrationName } from './applyMigrations';
13
+ export { IDENTITY_MIGRATIONS, UPGRADE_USER_IDENTITY, USERS_SCHEMA } from './migrations';
14
+ export { newAccountId, newAnchor, summarize } from './ResolvedAccount';
15
+ export type { AccountSummary, ResolvedAccount, UserRow, UserRowLike } from './ResolvedAccount';
16
+ export { UserDAO } from './UserDAO';
17
+ export { UserEmailDAO } from './UserEmailDAO';
18
+ export type { EmailClaim, UserEmailRow } from './UserEmailDAO';
19
+ export { UserIdentityService } from './UserIdentityService';
20
+ export type { UserIdentityDeps, UserIdentityEnv } from './UserIdentityService';
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACjF,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC1H,YAAY,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACxF,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACvE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@rexezuge/identity` — the account layer: the address is not the account.
3
+ *
4
+ * Converged from the seven repos that authenticate users (all but
5
+ * ChordDHT-Tracker, whose callers are certificates). Durable-DAV's invariants
6
+ * are the canonical ones because they are the strictest: the frozen anchor,
7
+ * the verified-only registry, and the fail-closed resolution order.
8
+ */
9
+ export { registerAccount, resolveAccount, resolveUserId } from './accountLookup';
10
+ export { applyIdentityMigrations, IDENTITY_MIGRATION_SQL, FRESH_MIGRATIONS, UPGRADE_MIGRATIONS } from './applyMigrations';
11
+ export { IDENTITY_MIGRATIONS, UPGRADE_USER_IDENTITY, USERS_SCHEMA } from './migrations';
12
+ export { newAccountId, newAnchor, summarize } from './ResolvedAccount';
13
+ export { UserDAO } from './UserDAO';
14
+ export { UserEmailDAO } from './UserEmailDAO';
15
+ export { UserIdentityService } from './UserIdentityService';
16
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEjF,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE1H,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACxF,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEvE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The canonical `users` / `user_emails` schema.
3
+ *
4
+ * Converged from migration `0001_init.sql` + `0004_user_identity.sql` across the
5
+ * identity-carrying repos, expressed as the *final* shape so a fresh database
6
+ * never has to be upgraded: `users.id` is the account key from the start, and
7
+ * `user_emails` exists from the start.
8
+ *
9
+ * The invariants that make an address change safe (Durable-DAV's design, which
10
+ * is the strictest in the family and the one every other repo's upgrade is
11
+ * migrating toward):
12
+ *
13
+ * - `users.id` is an opaque, unique account key — never an address.
14
+ * - `users.email` is the **frozen anchor**: immutable, the foreign-key target
15
+ * every legacy `*_email` value resolves against, so history survives a change.
16
+ * - `users.current_email` is the mutable sign-in address; unique, so an address
17
+ * can never be claimed by two accounts.
18
+ * - `users.username` is a separately-mutable public handle, not the identity.
19
+ * - `user_emails` is the address registry: `is_verified = 1` may authenticate;
20
+ * a changed-from address stays at `0` (attributable, non-authenticating) and
21
+ * is re-pointable by a later claimant.
22
+ *
23
+ * The anchor design is forced on D1: it honours neither
24
+ * `PRAGMA foreign_keys = off` nor `legacy_alter_table`, and `defer_foreign_keys`
25
+ * does not suppress `ON DELETE CASCADE`, so a live reference cannot be repointed
26
+ * without losing rows. A fresh install just starts with the anchor in place.
27
+ *
28
+ * A repo with legacy tables that reference `users(email)` applies this file's
29
+ * statements and then adds its own additive backfill (the converged pattern is
30
+ * in `upgrade-user-identity.sql`), repointing each legacy column through
31
+ * `user_emails` rather than through the anchor.
32
+ */
33
+ declare const USERS_SCHEMA = "\n-- The account. The anchor (email) is immutable; current_email is the login.\nCREATE TABLE IF NOT EXISTS users (\n id TEXT NOT NULL PRIMARY KEY,\n email TEXT NOT NULL UNIQUE,\n current_email TEXT NOT NULL UNIQUE,\n username TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_id ON users(id);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_current_email ON users(current_email);\n\n-- The address registry: which account an address authenticates.\nCREATE TABLE IF NOT EXISTS user_emails (\n email TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n is_verified INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_emails_user ON user_emails(user_id);\n";
34
+ /**
35
+ * The additive upgrade for a database whose `users` predates the identity
36
+ * columns — converged from `0004_user_identity.sql` (Durable-DAV) with the
37
+ * table-specific phases (dav_volumes, namespaces) generalized into a
38
+ * documented extension point, because every repo's legacy tables differ.
39
+ *
40
+ * Run order per phase matches Durable-DAV's, and each backfill is guarded so
41
+ * the file is rerunnable: `users.id` is only filled where still missing, and
42
+ * every registry insert is `INSERT OR IGNORE`.
43
+ *
44
+ * Why the address stays in `users` at all (the anchor question, answered once):
45
+ * SQLite rewrites a child's foreign key clause when the parent is renamed and
46
+ * drops the parent by cascading, so the reference from a legacy
47
+ * `owner_email`-style column cannot be repointed at `users.id` without losing
48
+ * rows. Keeping `email` as a frozen anchor sidesteps the rebuild entirely:
49
+ * this migration is purely additive.
50
+ *
51
+ * A consumer with its own legacy tables appends one additive phase per table:
52
+ * add the `*_user_id` column with `REFERENCES users(id) ON DELETE CASCADE`
53
+ * (`NO ACTION` would *restrict* where the existing `owner_email` cascade
54
+ * removed rows — the two references must cascade together), backfill it by
55
+ * resolving the stored address through `user_emails`, and index it.
56
+ */
57
+ declare const UPGRADE_USER_IDENTITY = "\n-- Phase 1: stable account key.\n-- SQLite cannot add a PRIMARY KEY column, so the id is a plain column with a\n-- unique index. A unique index is a valid foreign key parent, which is all the\n-- user_id references need.\nALTER TABLE users ADD COLUMN id TEXT;\n\nUPDATE users SET id = 'usr_' || lower(hex(randomblob(16))) WHERE id IS NULL;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_id ON users(id);\n\n-- Phase 2: mutable sign-in address.\n-- 'email' stays as the frozen anchor; 'current_email' is what the account signs\n-- in with and what the API reports. Uniqueness is enforced here, so an address\n-- can never be claimed by two accounts.\nALTER TABLE users ADD COLUMN current_email TEXT;\n\nUPDATE users SET current_email = lower(email) WHERE current_email IS NULL;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_current_email ON users(current_email);\n\n-- Phase 3: address registry.\n-- Login resolution consults is_verified = 1 only. Backfilled from the frozen\n-- anchor address of every existing account, lowercased so a legacy mixed-case\n-- row still yields exactly one login identity.\nCREATE TABLE IF NOT EXISTS user_emails (\n email TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n is_verified INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_emails_user ON user_emails(user_id);\n\nINSERT OR IGNORE INTO user_emails (email, user_id, is_verified, created_at)\nSELECT lower(email), id, 1, created_at FROM users;\n";
58
+ /**
59
+ * Every migration this package ships, in apply order.
60
+ *
61
+ * `upgrade-user-identity.sql` is *not* applied on a fresh database — the
62
+ * fresh-install path is `users.sql` alone, and the upgrade exists only for a
63
+ * legacy one.
64
+ */
65
+ declare const IDENTITY_MIGRATIONS: ReadonlyArray<{
66
+ name: string;
67
+ sql: string;
68
+ }>;
69
+ export { IDENTITY_MIGRATIONS, UPGRADE_USER_IDENTITY, USERS_SCHEMA };
70
+ //# sourceMappingURL=migrations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,QAAA,MAAM,YAAY,oyBAuBjB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,QAAA,MAAM,qBAAqB,2/CAoC1B,CAAC;AAEF;;;;;;GAMG;AACH,QAAA,MAAM,mBAAmB,EAAE,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAGrE,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,YAAY,EAAE,CAAC"}