@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rexezuge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `users` rows — the stored form of an account.
3
+ *
4
+ * Converged from `backend-data/src/dao/UserDAO.ts` + its `UserRow` (identical in
5
+ * six of the seven identity-carrying repos; AWS and ChordDHT keep only the
6
+ * id/email core).
7
+ *
8
+ * `id` is the stable account key. `email` is the *anchor*: an immutable value
9
+ * that legacy `*_email` foreign keys resolve against, so history stays
10
+ * resolvable across an address change. `current_email` is the address the
11
+ * account signs in with and the one APIs report.
12
+ */
13
+ export interface UserRow {
14
+ id?: string | null;
15
+ /**
16
+ * The frozen anchor address. Never updated — it is the foreign-key target
17
+ * every legacy `*_email` value resolves against, and rewriting it would
18
+ * cascade the rows that reference it. Read `current_email` for sign-in.
19
+ */
20
+ email: string;
21
+ /**
22
+ * Mutable sign-in address. Absent on a pre-identity database, where `email`
23
+ * *is* the sign-in address; read it as `current_email ?? email`.
24
+ */
25
+ current_email?: string | null;
26
+ created_at: number;
27
+ username: string | null;
28
+ updated_at: number | null;
29
+ }
30
+ /**
31
+ * A resolved account.
32
+ *
33
+ * `id` is the only value that should be used as an identity. `email` is the
34
+ * address the account currently signs in with; `anchorEmail` is the immutable
35
+ * value legacy columns store, and is what a pre-identity row can still be
36
+ * matched on.
37
+ */
38
+ export interface ResolvedAccount {
39
+ id: string;
40
+ email: string;
41
+ anchorEmail: string;
42
+ username: string | null;
43
+ }
44
+ /** The denormalized subset routes and the permission path use. */
45
+ export type AccountSummary = Pick<ResolvedAccount, 'id' | 'email' | 'username'>;
46
+ /** Shape every `users` read has, whether or not the identity migration has run. */
47
+ export type UserRowLike = Pick<UserRow, 'id' | 'email' | 'current_email' | 'username'> | undefined;
48
+ /** Project a `users` row onto a `ResolvedAccount`. */
49
+ export declare function summarize(row: UserRowLike): ResolvedAccount | null;
50
+ /**
51
+ * The opaque account id.
52
+ *
53
+ * Prefixed so an id is never mistaken for an address in a log line or a URL,
54
+ * and random at 128 bits so accounts are not enumerable.
55
+ */
56
+ export declare function newAccountId(): string;
57
+ /**
58
+ * Opaque anchor for an account whose login address is already held as another
59
+ * account's anchor.
60
+ *
61
+ * It must be globally unique and must never be a real address: `email` is the
62
+ * anchor every legacy `*_email` foreign key resolves against, so an address
63
+ * parked here could never be re-registered by a different person after its
64
+ * original account moved off it. `.invalid` is reserved by RFC 2606 and can
65
+ * never be delivered to, so it can never authenticate either.
66
+ */
67
+ export declare function newAnchor(): string;
68
+ //# sourceMappingURL=ResolvedAccount.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ResolvedAccount.d.ts","sourceRoot":"","sources":["../src/ResolvedAccount.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,OAAO;IACtB,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,kEAAkE;AAClE,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,eAAe,EAAE,IAAI,GAAG,OAAO,GAAG,UAAU,CAAC,CAAC;AAEhF,mFAAmF;AACnF,MAAM,MAAM,WAAW,GACnB,IAAI,CAAC,OAAO,EAAE,IAAI,GAAG,OAAO,GAAG,eAAe,GAAG,UAAU,CAAC,GAC5D,SAAS,CAAC;AAEd,sDAAsD;AACtD,wBAAgB,SAAS,CAAC,GAAG,EAAE,WAAW,GAAG,eAAe,GAAG,IAAI,CAIlE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED;;;;;;;;;GASG;AACH,wBAAgB,SAAS,IAAI,MAAM,CAElC"}
@@ -0,0 +1,30 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Project a `users` row onto a `ResolvedAccount`. */
3
+ export function summarize(row) {
4
+ return row?.id
5
+ ? { id: row.id, email: (row.current_email ?? row.email).toLowerCase(), anchorEmail: row.email, username: row.username ?? null }
6
+ : null;
7
+ }
8
+ /**
9
+ * The opaque account id.
10
+ *
11
+ * Prefixed so an id is never mistaken for an address in a log line or a URL,
12
+ * and random at 128 bits so accounts are not enumerable.
13
+ */
14
+ export function newAccountId() {
15
+ return `usr_${randomUUID().replaceAll('-', '')}`;
16
+ }
17
+ /**
18
+ * Opaque anchor for an account whose login address is already held as another
19
+ * account's anchor.
20
+ *
21
+ * It must be globally unique and must never be a real address: `email` is the
22
+ * anchor every legacy `*_email` foreign key resolves against, so an address
23
+ * parked here could never be re-registered by a different person after its
24
+ * original account moved off it. `.invalid` is reserved by RFC 2606 and can
25
+ * never be delivered to, so it can never authenticate either.
26
+ */
27
+ export function newAnchor() {
28
+ return `anchor-${randomUUID().replaceAll('-', '')}@users.invalid`;
29
+ }
30
+ //# sourceMappingURL=ResolvedAccount.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ResolvedAccount.js","sourceRoot":"","sources":["../src/ResolvedAccount.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAuDzC,sDAAsD;AACtD,MAAM,UAAU,SAAS,CAAC,GAAgB;IACxC,OAAO,GAAG,EAAE,EAAE;QACZ,CAAC,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,aAAa,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,EAAE,WAAW,EAAE,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,IAAI,IAAI,EAAE;QAC/H,CAAC,CAAC,IAAI,CAAC;AACX,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,OAAO,UAAU,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC;AACnD,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,SAAS;IACvB,OAAO,UAAU,UAAU,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,gBAAgB,CAAC;AACpE,CAAC"}
@@ -0,0 +1,88 @@
1
+ import { BaseDAO } from '@rexezuge/d1';
2
+ import type { D1Queryable } from '@rexezuge/d1';
3
+ import type { UserRow } from './ResolvedAccount';
4
+ /**
5
+ * The stored form of a handle: lowercase.
6
+ *
7
+ * Every username read and every username write goes through this, so the two
8
+ * can never disagree about case. That agreement is not cosmetic: `username` has
9
+ * no `COLLATE NOCASE` (SQLite defaults to BINARY), so a stored `Alice2` is a
10
+ * *different value* from `alice2` and a lookup that lowercased its parameter
11
+ * would never find it. Normalising at the write points rather than relying on
12
+ * each caller means a future caller cannot reintroduce an unfindable handle by
13
+ * forgetting.
14
+ */
15
+ declare function normalizeUsername(username: string): string;
16
+ /**
17
+ * `users` rows. Converged from `backend-data/src/dao/UserDAO.ts` (identical in
18
+ * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter, Mail-Meow; AWS keeps
19
+ * the id/email core without the username methods).
20
+ *
21
+ * Case rule everywhere in this DAO: lowercase the *parameter*, never the
22
+ * column — `lower(col)` makes the index or primary key unusable. It is only
23
+ * sound because every writer stores lowercase through this DAO's own
24
+ * normalisers.
25
+ */
26
+ export declare class UserDAO extends BaseDAO {
27
+ constructor(database: D1Queryable);
28
+ /**
29
+ * Create an account, stamped with the identity columns.
30
+ *
31
+ * `INSERT ... ON CONFLICT DO NOTHING` with **no conflict target**, deliberately.
32
+ * `users` carries two unique constraints — the anchor primary key and the
33
+ * unique index on `current_email` — and SQLite applies `DO NOTHING` only to
34
+ * the constraints a named target covers. Naming the anchor therefore left a
35
+ * `current_email` collision as a raised error, which callers cannot tell from
36
+ * a transient D1 failure: it propagated instead of returning `null`, so the
37
+ * opaque-anchor retry never ran and every login request answered 500.
38
+ *
39
+ * Returns the new account's id, or `null` when the insert was a silent no-op —
40
+ * which this DAO establishes by reading the anchor row back and requiring its
41
+ * own generated id to be the one there. The read-back lives here rather than
42
+ * in the caller because a caller that trusted the generated id on a taken
43
+ * anchor would point the address registry at a *phantom* account: the address
44
+ * then resolves to nothing, and the opaque-anchor retry fails the same way.
45
+ * Making the no-op detectable here is what keeps registration from stranding
46
+ * an address forever.
47
+ */
48
+ createUser(input: {
49
+ anchor: string;
50
+ loginEmail: string;
51
+ now: number;
52
+ }): Promise<string | null>;
53
+ /**
54
+ * @param idOrEmail The stable account key, or an anchor address as the
55
+ * pre-identity fallback. `WHERE id = ? OR email = ?` cannot use either index
56
+ * as a seek, so this stays off the hot path; login resolves through
57
+ * `UserIdentityService` instead.
58
+ */
59
+ ensureUsername(idOrEmail: string, username: string, now: number): Promise<void>;
60
+ /** @param idOrEmail The stable account key, or an anchor address. */
61
+ setUsername(idOrEmail: string, username: string, now: number): Promise<void>;
62
+ /**
63
+ * Anchor lookup — used for legacy `*_email` values and cascade resolution.
64
+ *
65
+ * This is *not* a login lookup: an account that has changed its address is not
66
+ * findable here by its new one. It is the pre-identity floor and the
67
+ * attribution read.
68
+ */
69
+ getByEmail(email: string): Promise<UserRow | undefined>;
70
+ /** The account behind a stable key. Null on a pre-identity database. */
71
+ getById(id: string): Promise<UserRow | undefined>;
72
+ /** The account currently signing in with this address. Identity-era only. */
73
+ getByCurrentEmail(email: string): Promise<UserRow | undefined>;
74
+ /**
75
+ * Move an account's sign-in address.
76
+ *
77
+ * The anchor `email` is deliberately untouched: it is what every legacy
78
+ * `*_email` column and foreign key resolves against. Ordering matters — the
79
+ * caller claims the new address first and revokes the old one third, so a
80
+ * failure between steps never leaves the account without a way to sign in.
81
+ */
82
+ setCurrentEmail(id: string, email: string, now: number): Promise<void>;
83
+ /** Lookup by handle. */
84
+ getByUsernameCi(usernameCi: string): Promise<UserRow | undefined>;
85
+ }
86
+ export { normalizeUsername };
87
+ export type { UserRow };
88
+ //# sourceMappingURL=UserDAO.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserDAO.d.ts","sourceRoot":"","sources":["../src/UserDAO.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEjD;;;;;;;;;;GAUG;AACH,iBAAS,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;;;;;;;GASG;AACH,qBAAa,OAAQ,SAAQ,OAAO;gBACf,QAAQ,EAAE,WAAW;IAIxC;;;;;;;;;;;;;;;;;;;OAmBG;IACU,UAAU,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAc3G;;;;;OAKG;IACU,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS5F,qEAAqE;IACxD,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASzF;;;;;;OAMG;IACU,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAIpE,wEAAwE;IAC3D,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAI9D,6EAA6E;IAChE,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAI3E;;;;;;;OAOG;IACU,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInF,wBAAwB;IACX,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;CAG/E;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,YAAY,EAAE,OAAO,EAAE,CAAC"}
@@ -0,0 +1,122 @@
1
+ import { BaseDAO } from '@rexezuge/d1';
2
+ import { newAccountId } from './ResolvedAccount';
3
+ /**
4
+ * The stored form of a handle: lowercase.
5
+ *
6
+ * Every username read and every username write goes through this, so the two
7
+ * can never disagree about case. That agreement is not cosmetic: `username` has
8
+ * no `COLLATE NOCASE` (SQLite defaults to BINARY), so a stored `Alice2` is a
9
+ * *different value* from `alice2` and a lookup that lowercased its parameter
10
+ * would never find it. Normalising at the write points rather than relying on
11
+ * each caller means a future caller cannot reintroduce an unfindable handle by
12
+ * forgetting.
13
+ */
14
+ function normalizeUsername(username) {
15
+ return username.trim().toLowerCase();
16
+ }
17
+ /**
18
+ * `users` rows. Converged from `backend-data/src/dao/UserDAO.ts` (identical in
19
+ * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter, Mail-Meow; AWS keeps
20
+ * the id/email core without the username methods).
21
+ *
22
+ * Case rule everywhere in this DAO: lowercase the *parameter*, never the
23
+ * column — `lower(col)` makes the index or primary key unusable. It is only
24
+ * sound because every writer stores lowercase through this DAO's own
25
+ * normalisers.
26
+ */
27
+ export class UserDAO extends BaseDAO {
28
+ constructor(database) {
29
+ super(database);
30
+ }
31
+ /**
32
+ * Create an account, stamped with the identity columns.
33
+ *
34
+ * `INSERT ... ON CONFLICT DO NOTHING` with **no conflict target**, deliberately.
35
+ * `users` carries two unique constraints — the anchor primary key and the
36
+ * unique index on `current_email` — and SQLite applies `DO NOTHING` only to
37
+ * the constraints a named target covers. Naming the anchor therefore left a
38
+ * `current_email` collision as a raised error, which callers cannot tell from
39
+ * a transient D1 failure: it propagated instead of returning `null`, so the
40
+ * opaque-anchor retry never ran and every login request answered 500.
41
+ *
42
+ * Returns the new account's id, or `null` when the insert was a silent no-op —
43
+ * which this DAO establishes by reading the anchor row back and requiring its
44
+ * own generated id to be the one there. The read-back lives here rather than
45
+ * in the caller because a caller that trusted the generated id on a taken
46
+ * anchor would point the address registry at a *phantom* account: the address
47
+ * then resolves to nothing, and the opaque-anchor retry fails the same way.
48
+ * Making the no-op detectable here is what keeps registration from stranding
49
+ * an address forever.
50
+ */
51
+ async createUser(input) {
52
+ const anchor = input.anchor.toLowerCase();
53
+ const loginEmail = input.loginEmail.toLowerCase();
54
+ const id = newAccountId();
55
+ await this.run('INSERT INTO users (email, created_at, id, current_email) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING', [
56
+ anchor,
57
+ input.now,
58
+ id,
59
+ loginEmail,
60
+ ]);
61
+ const row = await this.getByEmail(anchor);
62
+ return row?.id === id ? id : null;
63
+ }
64
+ /**
65
+ * @param idOrEmail The stable account key, or an anchor address as the
66
+ * pre-identity fallback. `WHERE id = ? OR email = ?` cannot use either index
67
+ * as a seek, so this stays off the hot path; login resolves through
68
+ * `UserIdentityService` instead.
69
+ */
70
+ async ensureUsername(idOrEmail, username, now) {
71
+ await this.run('UPDATE users SET username = COALESCE(username, ?), updated_at = COALESCE(updated_at, ?) WHERE id = ? OR email = ?', [
72
+ normalizeUsername(username),
73
+ now,
74
+ idOrEmail,
75
+ idOrEmail,
76
+ ]);
77
+ }
78
+ /** @param idOrEmail The stable account key, or an anchor address. */
79
+ async setUsername(idOrEmail, username, now) {
80
+ await this.run('UPDATE users SET username = ?, updated_at = ? WHERE id = ? OR email = ?', [
81
+ normalizeUsername(username),
82
+ now,
83
+ idOrEmail,
84
+ idOrEmail,
85
+ ]);
86
+ }
87
+ /**
88
+ * Anchor lookup — used for legacy `*_email` values and cascade resolution.
89
+ *
90
+ * This is *not* a login lookup: an account that has changed its address is not
91
+ * findable here by its new one. It is the pre-identity floor and the
92
+ * attribution read.
93
+ */
94
+ async getByEmail(email) {
95
+ return this.first('SELECT * FROM users WHERE email = ? LIMIT 1', [email.toLowerCase()]);
96
+ }
97
+ /** The account behind a stable key. Null on a pre-identity database. */
98
+ async getById(id) {
99
+ return this.first('SELECT * FROM users WHERE id = ? LIMIT 1', [id]);
100
+ }
101
+ /** The account currently signing in with this address. Identity-era only. */
102
+ async getByCurrentEmail(email) {
103
+ return this.first('SELECT * FROM users WHERE current_email = ? LIMIT 1', [email.toLowerCase()]);
104
+ }
105
+ /**
106
+ * Move an account's sign-in address.
107
+ *
108
+ * The anchor `email` is deliberately untouched: it is what every legacy
109
+ * `*_email` column and foreign key resolves against. Ordering matters — the
110
+ * caller claims the new address first and revokes the old one third, so a
111
+ * failure between steps never leaves the account without a way to sign in.
112
+ */
113
+ async setCurrentEmail(id, email, now) {
114
+ await this.run('UPDATE users SET current_email = ?, updated_at = ? WHERE id = ?', [email.toLowerCase(), now, id]);
115
+ }
116
+ /** Lookup by handle. */
117
+ async getByUsernameCi(usernameCi) {
118
+ return this.first('SELECT * FROM users WHERE username = ? LIMIT 1', [usernameCi.toLowerCase()]);
119
+ }
120
+ }
121
+ export { normalizeUsername };
122
+ //# sourceMappingURL=UserDAO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserDAO.js","sourceRoot":"","sources":["../src/UserDAO.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAGjD;;;;;;;;;;GAUG;AACH,SAAS,iBAAiB,CAAC,QAAgB;IACzC,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AACvC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,OAAO,OAAQ,SAAQ,OAAO;IAClC,YAAmB,QAAqB;QACtC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAClB,CAAC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACI,KAAK,CAAC,UAAU,CAAC,KAA0D;QAChF,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;QAC1C,MAAM,UAAU,GAAG,KAAK,CAAC,UAAU,CAAC,WAAW,EAAE,CAAC;QAClD,MAAM,EAAE,GAAG,YAAY,EAAE,CAAC;QAC1B,MAAM,IAAI,CAAC,GAAG,CAAC,qGAAqG,EAAE;YACpH,MAAM;YACN,KAAK,CAAC,GAAG;YACT,EAAE;YACF,UAAU;SACX,CAAC,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAC1C,OAAO,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IACpC,CAAC;IAED;;;;;OAKG;IACI,KAAK,CAAC,cAAc,CAAC,SAAiB,EAAE,QAAgB,EAAE,GAAW;QAC1E,MAAM,IAAI,CAAC,GAAG,CAAC,mHAAmH,EAAE;YAClI,iBAAiB,CAAC,QAAQ,CAAC;YAC3B,GAAG;YACH,SAAS;YACT,SAAS;SACV,CAAC,CAAC;IACL,CAAC;IAED,qEAAqE;IAC9D,KAAK,CAAC,WAAW,CAAC,SAAiB,EAAE,QAAgB,EAAE,GAAW;QACvE,MAAM,IAAI,CAAC,GAAG,CAAC,yEAAyE,EAAE;YACxF,iBAAiB,CAAC,QAAQ,CAAC;YAC3B,GAAG;YACH,SAAS;YACT,SAAS;SACV,CAAC,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACI,KAAK,CAAC,UAAU,CAAC,KAAa;QACnC,OAAO,IAAI,CAAC,KAAK,CAAU,6CAA6C,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IACnG,CAAC;IAED,wEAAwE;IACjE,KAAK,CAAC,OAAO,CAAC,EAAU;QAC7B,OAAO,IAAI,CAAC,KAAK,CAAU,0CAA0C,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAC/E,CAAC;IAED,6EAA6E;IACtE,KAAK,CAAC,iBAAiB,CAAC,KAAa;QAC1C,OAAO,IAAI,CAAC,KAAK,CAAU,qDAAqD,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAC3G,CAAC;IAED;;;;;;;OAOG;IACI,KAAK,CAAC,eAAe,CAAC,EAAU,EAAE,KAAa,EAAE,GAAW;QACjE,MAAM,IAAI,CAAC,GAAG,CAAC,iEAAiE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC;IACpH,CAAC;IAED,wBAAwB;IACjB,KAAK,CAAC,eAAe,CAAC,UAAkB;QAC7C,OAAO,IAAI,CAAC,KAAK,CAAU,gDAAgD,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAC3G,CAAC;CACF;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAC"}
@@ -0,0 +1,50 @@
1
+ import { BaseDAO } from '@rexezuge/d1';
2
+ import type { D1Queryable } from '@rexezuge/d1';
3
+ /**
4
+ * One known address for an account.
5
+ *
6
+ * `is_verified` gates login: `1` means the address may authenticate the
7
+ * account, `0` means it was changed away from and is retained only so rows
8
+ * written before the change still resolve. A revoked row is re-pointed (not
9
+ * deleted) when a later account legitimately claims the address, so an address
10
+ * is never permanently reserved.
11
+ *
12
+ * Converged from `backend-data/src/dao/UserEmailDAO.ts` (identical in Durable-DAV,
13
+ * Durable-DAV-Router, Edge-Git, Mail-Otter).
14
+ */
15
+ export interface UserEmailRow {
16
+ email: string;
17
+ user_id: string;
18
+ is_verified: number;
19
+ created_at: number;
20
+ }
21
+ export type EmailClaim = 'claimed' | 'already-claimed';
22
+ export declare class UserEmailDAO extends BaseDAO {
23
+ constructor(database: D1Queryable);
24
+ /**
25
+ * Claim an address for an account.
26
+ *
27
+ * An existing verified row is left alone: the address already belongs to
28
+ * someone, and silently re-pointing it would hand one account's identity to
29
+ * another. Callers check `resolveVerified` first and reject on a hit.
30
+ * An unverified (revoked) row is re-pointed, which releases the address.
31
+ */
32
+ register(input: {
33
+ email: string;
34
+ userId: string;
35
+ isVerified: boolean;
36
+ now: number;
37
+ }): Promise<EmailClaim>;
38
+ get(email: string): Promise<UserEmailRow | undefined>;
39
+ /** Login resolution: only a verified address identifies an account. */
40
+ resolveVerified(email: string): Promise<UserEmailRow | undefined>;
41
+ listByUserId(userId: string): Promise<UserEmailRow[]>;
42
+ /** Revoke an address for login while keeping it resolvable for attribution. */
43
+ revoke(email: string): Promise<void>;
44
+ /**
45
+ * Revoke every verified address for an account. Used when an account's login
46
+ * address changes, so only the new address can authenticate it.
47
+ */
48
+ revokeAllVerified(userId: string, exceptEmail: string): Promise<void>;
49
+ }
50
+ //# sourceMappingURL=UserEmailDAO.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserEmailDAO.d.ts","sourceRoot":"","sources":["../src/UserEmailDAO.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,iBAAiB,CAAC;AAEvD,qBAAa,YAAa,SAAQ,OAAO;gBACpB,QAAQ,EAAE,WAAW;IAIxC;;;;;;;OAOG;IACU,QAAQ,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,CAAC;IAazG,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;IAOlE,uEAAuE;IAC1D,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;IAIjE,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAIlE,+EAA+E;IAClE,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjD;;;OAGG;IACU,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGnF"}
@@ -0,0 +1,49 @@
1
+ import { BaseDAO } from '@rexezuge/d1';
2
+ export class UserEmailDAO extends BaseDAO {
3
+ constructor(database) {
4
+ super(database);
5
+ }
6
+ /**
7
+ * Claim an address for an account.
8
+ *
9
+ * An existing verified row is left alone: the address already belongs to
10
+ * someone, and silently re-pointing it would hand one account's identity to
11
+ * another. Callers check `resolveVerified` first and reject on a hit.
12
+ * An unverified (revoked) row is re-pointed, which releases the address.
13
+ */
14
+ async register(input) {
15
+ const email = input.email.toLowerCase();
16
+ const existing = await this.get(email);
17
+ if (existing && existing.is_verified === 1)
18
+ return 'already-claimed';
19
+ await this.run(`INSERT INTO user_emails (email, user_id, is_verified, created_at)
20
+ VALUES (?, ?, ?, ?)
21
+ ON CONFLICT(email) DO UPDATE SET user_id = excluded.user_id, is_verified = excluded.is_verified`, [email, input.userId, input.isVerified ? 1 : 0, input.now]);
22
+ return 'claimed';
23
+ }
24
+ async get(email) {
25
+ // Lowercase the *parameter*: the column is stored lowercased by every
26
+ // writer, so `lower(email) = ?` matched identically while making the
27
+ // primary key unusable.
28
+ return this.first('SELECT * FROM user_emails WHERE email = ? LIMIT 1', [email.toLowerCase()]);
29
+ }
30
+ /** Login resolution: only a verified address identifies an account. */
31
+ async resolveVerified(email) {
32
+ return this.first('SELECT * FROM user_emails WHERE email = ? AND is_verified = 1 LIMIT 1', [email.toLowerCase()]);
33
+ }
34
+ async listByUserId(userId) {
35
+ return this.all('SELECT * FROM user_emails WHERE user_id = ? ORDER BY is_verified DESC, created_at ASC', [userId]);
36
+ }
37
+ /** Revoke an address for login while keeping it resolvable for attribution. */
38
+ async revoke(email) {
39
+ await this.run('UPDATE user_emails SET is_verified = 0 WHERE email = ?', [email.toLowerCase()]);
40
+ }
41
+ /**
42
+ * Revoke every verified address for an account. Used when an account's login
43
+ * address changes, so only the new address can authenticate it.
44
+ */
45
+ async revokeAllVerified(userId, exceptEmail) {
46
+ await this.run('UPDATE user_emails SET is_verified = 0 WHERE user_id = ? AND email != ?', [userId, exceptEmail.toLowerCase()]);
47
+ }
48
+ }
49
+ //# sourceMappingURL=UserEmailDAO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserEmailDAO.js","sourceRoot":"","sources":["../src/UserEmailDAO.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAwBvC,MAAM,OAAO,YAAa,SAAQ,OAAO;IACvC,YAAmB,QAAqB;QACtC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAClB,CAAC;IAED;;;;;;;OAOG;IACI,KAAK,CAAC,QAAQ,CAAC,KAA0E;QAC9F,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QACxC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACvC,IAAI,QAAQ,IAAI,QAAQ,CAAC,WAAW,KAAK,CAAC;YAAE,OAAO,iBAAiB,CAAC;QACrE,MAAM,IAAI,CAAC,GAAG,CACZ;;uGAEiG,EACjG,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAC3D,CAAC;QACF,OAAO,SAAS,CAAC;IACnB,CAAC;IAEM,KAAK,CAAC,GAAG,CAAC,KAAa;QAC5B,sEAAsE;QACtE,qEAAqE;QACrE,wBAAwB;QACxB,OAAO,IAAI,CAAC,KAAK,CAAe,mDAAmD,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAC9G,CAAC;IAED,uEAAuE;IAChE,KAAK,CAAC,eAAe,CAAC,KAAa;QACxC,OAAO,IAAI,CAAC,KAAK,CAAe,uEAAuE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAClI,CAAC;IAEM,KAAK,CAAC,YAAY,CAAC,MAAc;QACtC,OAAO,IAAI,CAAC,GAAG,CAAe,uFAAuF,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IACnI,CAAC;IAED,+EAA+E;IACxE,KAAK,CAAC,MAAM,CAAC,KAAa;QAC/B,MAAM,IAAI,CAAC,GAAG,CAAC,wDAAwD,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAClG,CAAC;IAED;;;OAGG;IACI,KAAK,CAAC,iBAAiB,CAAC,MAAc,EAAE,WAAmB;QAChE,MAAM,IAAI,CAAC,GAAG,CAAC,yEAAyE,EAAE,CAAC,MAAM,EAAE,WAAW,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IACjI,CAAC;CACF"}
@@ -0,0 +1,91 @@
1
+ import type { D1Queryable } from '@rexezuge/d1';
2
+ import type { AccountLookupDeps } from './accountLookup';
3
+ import type { ResolvedAccount } from './ResolvedAccount';
4
+ interface UserIdentityEnv {
5
+ DB: D1Queryable;
6
+ }
7
+ export type { UserIdentityEnv };
8
+ /**
9
+ * Injected DAO factories. All optional: each defaults to a real DAO over the
10
+ * same D1 binding, so tests override only what they need.
11
+ */
12
+ export type UserIdentityDeps = Partial<AccountLookupDeps>;
13
+ /**
14
+ * The account behind an address, plus the address-change operations.
15
+ *
16
+ * Converged from `backend-services/src/identity/UserIdentityService.ts`
17
+ * (identical in Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter; AWS and
18
+ * Mail-Meow keep the resolve core). Canonical names merge the two spellings the
19
+ * repos used — `resolveUserById` (Durable-DAV family) and
20
+ * `resolveAccountById` (AWS) are one method, `resolveAccountById`.
21
+ *
22
+ * The instance is one per request scope: the ownership and permission paths
23
+ * resolve the same caller several times each, and a per-instance address memo
24
+ * costs one query per request rather than one per comparison. Bind it once in
25
+ * the composition root and share it — constructing per consumer is the bug the
26
+ * memo exists to prevent.
27
+ *
28
+ * Resolution itself lives in `accountLookup` (free functions, shared with any
29
+ * account bootstrap); this class adds memoization and the change operations.
30
+ */
31
+ export declare class UserIdentityService {
32
+ private readonly deps;
33
+ private readonly byEmail;
34
+ constructor(env: UserIdentityEnv, deps?: UserIdentityDeps);
35
+ /**
36
+ * Resolve a sign-in address to its account, or null when unknown.
37
+ *
38
+ * Only a verified address resolves. A revoked address (changed away from) is
39
+ * retained so pre-change rows stay attributable, but it must never
40
+ * authenticate, otherwise a reassigned address would inherit the previous
41
+ * holder's account.
42
+ */
43
+ resolveAccount(email: string): Promise<ResolvedAccount | null>;
44
+ /** Account id for a sign-in address, or null when unknown. */
45
+ resolveAccountId(email: string): Promise<string | null>;
46
+ /**
47
+ * Account behind a stable key. The inverse direction, for a caller that
48
+ * already holds one (a resource owner) and needs the current address.
49
+ */
50
+ resolveAccountById(userId: string): Promise<ResolvedAccount | null>;
51
+ /**
52
+ * Resolve-or-create, for a login path that may see a first-time user.
53
+ *
54
+ * Returns null when the address exists but cannot be resolved (a revoked
55
+ * address) — a caller that needs "guaranteed account" must distinguish that
56
+ * from "created now" through its own route, because silently registering on a
57
+ * revoked address would hand the previous holder's history to the new one.
58
+ */
59
+ resolveOrRegister(email: string, now?: number): Promise<ResolvedAccount | null>;
60
+ /**
61
+ * Every address known for an account, verified ones first.
62
+ */
63
+ listAddresses(userId: string): Promise<Array<{
64
+ email: string;
65
+ isVerified: boolean;
66
+ }>>;
67
+ /**
68
+ * Point an account at a new sign-in address.
69
+ *
70
+ * The account id, the frozen anchor, and every id-keyed row are untouched:
71
+ * only which address authenticates the account moves. The previous address is
72
+ * revoked rather than deleted, so rows written before the change still resolve
73
+ * to this account, and it is released for a later legitimate holder.
74
+ *
75
+ * Rejects an address already verified for another account. That check is the
76
+ * whole reason this is not simply an `UPDATE`: an unverified change would let
77
+ * anyone claim an address and inherit its history.
78
+ *
79
+ * Order is load-bearing, and it is the one the ops CLI (`change-email.ts`)
80
+ * already gets right: claim first, move `current_email` second, revoke third.
81
+ * Inverting it opens a window in which nothing authenticates if a later step
82
+ * fails.
83
+ */
84
+ setPrimaryEmail(userId: string, newEmail: string, now?: number): Promise<ResolvedAccount>;
85
+ /**
86
+ * Ops/migration path: attach an address that has already been proven, without
87
+ * making it the sign-in address.
88
+ */
89
+ linkVerifiedEmail(userId: string, email: string, now?: number): Promise<void>;
90
+ }
91
+ //# sourceMappingURL=UserIdentityService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserIdentityService.d.ts","sourceRoot":"","sources":["../src/UserIdentityService.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAIhD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAIzD,UAAU,eAAe;IACvB,EAAE,EAAE,WAAW,CAAC;CACjB;AAED,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA8B;IAEnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6C;gBAElD,GAAG,EAAE,eAAe,EAAE,IAAI,GAAE,gBAAqB;IAQpE;;;;;;;OAOG;IACU,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAS3E,8DAA8D;IACjD,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAKpE;;;OAGG;IACU,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAMhF;;;;;;;OAOG;IACU,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAmD,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAYtI;;OAEG;IACU,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAMlG;;;;;;;;;;;;;;;;OAgBG;IACU,eAAe,CAC1B,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,GAAG,SAAmD,GACrD,OAAO,CAAC,eAAe,CAAC;IAsB3B;;;OAGG;IACU,iBAAiB,CAC5B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,GAAG,SAAmD,GACrD,OAAO,CAAC,IAAI,CAAC;CAOjB"}