@rexezuge/identity 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ResolvedAccount.d.ts +9 -2
- package/dist/ResolvedAccount.d.ts.map +1 -1
- package/dist/ResolvedAccount.js.map +1 -1
- package/dist/UserDAO.d.ts +4 -4
- package/dist/UserDAO.d.ts.map +1 -1
- package/dist/UserDAO.js.map +1 -1
- package/dist/UserEmailDAO.d.ts +2 -2
- package/dist/UserEmailDAO.d.ts.map +1 -1
- package/dist/UserEmailDAO.js.map +1 -1
- package/dist/accountLookup.d.ts.map +1 -1
- package/dist/accountLookup.js +5 -0
- package/dist/accountLookup.js.map +1 -1
- package/package.json +4 -4
|
@@ -43,8 +43,15 @@ export interface ResolvedAccount {
|
|
|
43
43
|
}
|
|
44
44
|
/** The denormalized subset routes and the permission path use. */
|
|
45
45
|
export type AccountSummary = Pick<ResolvedAccount, 'id' | 'email' | 'username'>;
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Shape every `users` read has, whether or not the identity migration has run.
|
|
48
|
+
*
|
|
49
|
+
* `| null` for the absent row because that is what `UserDAO` returns now — the
|
|
50
|
+
* DAO layer answers `null` for "no row", matching D1 and KV. The union is
|
|
51
|
+
* widened into `summarize` rather than normalised there, so `summarize` accepts
|
|
52
|
+
* either a row or the absence of one without a caller rewriting it first.
|
|
53
|
+
*/
|
|
54
|
+
export type UserRowLike = Pick<UserRow, 'id' | 'email' | 'current_email' | 'username'> | null;
|
|
48
55
|
/** Project a `users` row onto a `ResolvedAccount`. */
|
|
49
56
|
export declare function summarize(row: UserRowLike): ResolvedAccount | null;
|
|
50
57
|
/**
|
|
@@ -1 +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
|
|
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;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,EAAE,IAAI,GAAG,OAAO,GAAG,eAAe,GAAG,UAAU,CAAC,GAAG,IAAI,CAAC;AAE9F,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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ResolvedAccount.js","sourceRoot":"","sources":["../src/ResolvedAccount.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"ResolvedAccount.js","sourceRoot":"","sources":["../src/ResolvedAccount.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA4DzC,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","sourcesContent":["import { randomUUID } from 'node:crypto';\n\n/**\n * `users` rows — the stored form of an account.\n *\n * Converged from `backend-data/src/dao/UserDAO.ts` + its `UserRow` (identical in\n * six of the seven identity-carrying repos; AWS and ChordDHT keep only the\n * id/email core).\n *\n * `id` is the stable account key. `email` is the *anchor*: an immutable value\n * that legacy `*_email` foreign keys resolve against, so history stays\n * resolvable across an address change. `current_email` is the address the\n * account signs in with and the one APIs report.\n */\nexport interface UserRow {\n id?: string | null;\n /**\n * The frozen anchor address. Never updated — it is the foreign-key target\n * every legacy `*_email` value resolves against, and rewriting it would\n * cascade the rows that reference it. Read `current_email` for sign-in.\n */\n email: string;\n /**\n * Mutable sign-in address. Absent on a pre-identity database, where `email`\n * *is* the sign-in address; read it as `current_email ?? email`.\n */\n current_email?: string | null;\n created_at: number;\n username: string | null;\n updated_at: number | null;\n}\n\n/**\n * A resolved account.\n *\n * `id` is the only value that should be used as an identity. `email` is the\n * address the account currently signs in with; `anchorEmail` is the immutable\n * value legacy columns store, and is what a pre-identity row can still be\n * matched on.\n */\nexport interface ResolvedAccount {\n id: string;\n email: string;\n anchorEmail: string;\n username: string | null;\n}\n\n/** The denormalized subset routes and the permission path use. */\nexport type AccountSummary = Pick<ResolvedAccount, 'id' | 'email' | 'username'>;\n\n/**\n * Shape every `users` read has, whether or not the identity migration has run.\n *\n * `| null` for the absent row because that is what `UserDAO` returns now — the\n * DAO layer answers `null` for \"no row\", matching D1 and KV. The union is\n * widened into `summarize` rather than normalised there, so `summarize` accepts\n * either a row or the absence of one without a caller rewriting it first.\n */\nexport type UserRowLike = Pick<UserRow, 'id' | 'email' | 'current_email' | 'username'> | null;\n\n/** Project a `users` row onto a `ResolvedAccount`. */\nexport function summarize(row: UserRowLike): ResolvedAccount | null {\n return row?.id\n ? { id: row.id, email: (row.current_email ?? row.email).toLowerCase(), anchorEmail: row.email, username: row.username ?? null }\n : null;\n}\n\n/**\n * The opaque account id.\n *\n * Prefixed so an id is never mistaken for an address in a log line or a URL,\n * and random at 128 bits so accounts are not enumerable.\n */\nexport function newAccountId(): string {\n return `usr_${randomUUID().replaceAll('-', '')}`;\n}\n\n/**\n * Opaque anchor for an account whose login address is already held as another\n * account's anchor.\n *\n * It must be globally unique and must never be a real address: `email` is the\n * anchor every legacy `*_email` foreign key resolves against, so an address\n * parked here could never be re-registered by a different person after its\n * original account moved off it. `.invalid` is reserved by RFC 2606 and can\n * never be delivered to, so it can never authenticate either.\n */\nexport function newAnchor(): string {\n return `anchor-${randomUUID().replaceAll('-', '')}@users.invalid`;\n}\n"]}
|
package/dist/UserDAO.d.ts
CHANGED
|
@@ -66,11 +66,11 @@ export declare class UserDAO extends BaseDAO {
|
|
|
66
66
|
* findable here by its new one. It is the pre-identity floor and the
|
|
67
67
|
* attribution read.
|
|
68
68
|
*/
|
|
69
|
-
getByEmail(email: string): Promise<UserRow |
|
|
69
|
+
getByEmail(email: string): Promise<UserRow | null>;
|
|
70
70
|
/** The account behind a stable key. Null on a pre-identity database. */
|
|
71
|
-
getById(id: string): Promise<UserRow |
|
|
71
|
+
getById(id: string): Promise<UserRow | null>;
|
|
72
72
|
/** The account currently signing in with this address. Identity-era only. */
|
|
73
|
-
getByCurrentEmail(email: string): Promise<UserRow |
|
|
73
|
+
getByCurrentEmail(email: string): Promise<UserRow | null>;
|
|
74
74
|
/**
|
|
75
75
|
* Move an account's sign-in address.
|
|
76
76
|
*
|
|
@@ -81,7 +81,7 @@ export declare class UserDAO extends BaseDAO {
|
|
|
81
81
|
*/
|
|
82
82
|
setCurrentEmail(id: string, email: string, now: number): Promise<void>;
|
|
83
83
|
/** Lookup by handle. */
|
|
84
|
-
getByUsernameCi(usernameCi: string): Promise<UserRow |
|
|
84
|
+
getByUsernameCi(usernameCi: string): Promise<UserRow | null>;
|
|
85
85
|
}
|
|
86
86
|
export { normalizeUsername };
|
|
87
87
|
export type { UserRow };
|
package/dist/UserDAO.d.ts.map
CHANGED
|
@@ -1 +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,
|
|
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,IAAI,CAAC;IAI/D,wEAAwE;IAC3D,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAIzD,6EAA6E;IAChE,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAItE;;;;;;;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,IAAI,CAAC;CAG1E;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,YAAY,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/UserDAO.js.map
CHANGED
|
@@ -1 +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","sourcesContent":["import { BaseDAO } from '@rexezuge/d1';\nimport type { D1Queryable } from '@rexezuge/d1';\nimport { newAccountId } from './ResolvedAccount';\nimport type { UserRow } from './ResolvedAccount';\n\n/**\n * The stored form of a handle: lowercase.\n *\n * Every username read and every username write goes through this, so the two\n * can never disagree about case. That agreement is not cosmetic: `username` has\n * no `COLLATE NOCASE` (SQLite defaults to BINARY), so a stored `Alice2` is a\n * *different value* from `alice2` and a lookup that lowercased its parameter\n * would never find it. Normalising at the write points rather than relying on\n * each caller means a future caller cannot reintroduce an unfindable handle by\n * forgetting.\n */\nfunction normalizeUsername(username: string): string {\n return username.trim().toLowerCase();\n}\n\n/**\n * `users` rows. Converged from `backend-data/src/dao/UserDAO.ts` (identical in\n * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter, Mail-Meow; AWS keeps\n * the id/email core without the username methods).\n *\n * Case rule everywhere in this DAO: lowercase the *parameter*, never the\n * column — `lower(col)` makes the index or primary key unusable. It is only\n * sound because every writer stores lowercase through this DAO's own\n * normalisers.\n */\nexport class UserDAO extends BaseDAO {\n public constructor(database: D1Queryable) {\n super(database);\n }\n\n /**\n * Create an account, stamped with the identity columns.\n *\n * `INSERT ... ON CONFLICT DO NOTHING` with **no conflict target**, deliberately.\n * `users` carries two unique constraints — the anchor primary key and the\n * unique index on `current_email` — and SQLite applies `DO NOTHING` only to\n * the constraints a named target covers. Naming the anchor therefore left a\n * `current_email` collision as a raised error, which callers cannot tell from\n * a transient D1 failure: it propagated instead of returning `null`, so the\n * opaque-anchor retry never ran and every login request answered 500.\n *\n * Returns the new account's id, or `null` when the insert was a silent no-op —\n * which this DAO establishes by reading the anchor row back and requiring its\n * own generated id to be the one there. The read-back lives here rather than\n * in the caller because a caller that trusted the generated id on a taken\n * anchor would point the address registry at a *phantom* account: the address\n * then resolves to nothing, and the opaque-anchor retry fails the same way.\n * Making the no-op detectable here is what keeps registration from stranding\n * an address forever.\n */\n public async createUser(input: { anchor: string; loginEmail: string; now: number }): Promise<string | null> {\n const anchor = input.anchor.toLowerCase();\n const loginEmail = input.loginEmail.toLowerCase();\n const id = newAccountId();\n await this.run('INSERT INTO users (email, created_at, id, current_email) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING', [\n anchor,\n input.now,\n id,\n loginEmail,\n ]);\n const row = await this.getByEmail(anchor);\n return row?.id === id ? id : null;\n }\n\n /**\n * @param idOrEmail The stable account key, or an anchor address as the\n * pre-identity fallback. `WHERE id = ? OR email = ?` cannot use either index\n * as a seek, so this stays off the hot path; login resolves through\n * `UserIdentityService` instead.\n */\n public async ensureUsername(idOrEmail: string, username: string, now: number): Promise<void> {\n await this.run('UPDATE users SET username = COALESCE(username, ?), updated_at = COALESCE(updated_at, ?) WHERE id = ? OR email = ?', [\n normalizeUsername(username),\n now,\n idOrEmail,\n idOrEmail,\n ]);\n }\n\n /** @param idOrEmail The stable account key, or an anchor address. */\n public async setUsername(idOrEmail: string, username: string, now: number): Promise<void> {\n await this.run('UPDATE users SET username = ?, updated_at = ? WHERE id = ? OR email = ?', [\n normalizeUsername(username),\n now,\n idOrEmail,\n idOrEmail,\n ]);\n }\n\n /**\n * Anchor lookup — used for legacy `*_email` values and cascade resolution.\n *\n * This is *not* a login lookup: an account that has changed its address is not\n * findable here by its new one. It is the pre-identity floor and the\n * attribution read.\n */\n public async getByEmail(email: string): Promise<UserRow |
|
|
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","sourcesContent":["import { BaseDAO } from '@rexezuge/d1';\nimport type { D1Queryable } from '@rexezuge/d1';\nimport { newAccountId } from './ResolvedAccount';\nimport type { UserRow } from './ResolvedAccount';\n\n/**\n * The stored form of a handle: lowercase.\n *\n * Every username read and every username write goes through this, so the two\n * can never disagree about case. That agreement is not cosmetic: `username` has\n * no `COLLATE NOCASE` (SQLite defaults to BINARY), so a stored `Alice2` is a\n * *different value* from `alice2` and a lookup that lowercased its parameter\n * would never find it. Normalising at the write points rather than relying on\n * each caller means a future caller cannot reintroduce an unfindable handle by\n * forgetting.\n */\nfunction normalizeUsername(username: string): string {\n return username.trim().toLowerCase();\n}\n\n/**\n * `users` rows. Converged from `backend-data/src/dao/UserDAO.ts` (identical in\n * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter, Mail-Meow; AWS keeps\n * the id/email core without the username methods).\n *\n * Case rule everywhere in this DAO: lowercase the *parameter*, never the\n * column — `lower(col)` makes the index or primary key unusable. It is only\n * sound because every writer stores lowercase through this DAO's own\n * normalisers.\n */\nexport class UserDAO extends BaseDAO {\n public constructor(database: D1Queryable) {\n super(database);\n }\n\n /**\n * Create an account, stamped with the identity columns.\n *\n * `INSERT ... ON CONFLICT DO NOTHING` with **no conflict target**, deliberately.\n * `users` carries two unique constraints — the anchor primary key and the\n * unique index on `current_email` — and SQLite applies `DO NOTHING` only to\n * the constraints a named target covers. Naming the anchor therefore left a\n * `current_email` collision as a raised error, which callers cannot tell from\n * a transient D1 failure: it propagated instead of returning `null`, so the\n * opaque-anchor retry never ran and every login request answered 500.\n *\n * Returns the new account's id, or `null` when the insert was a silent no-op —\n * which this DAO establishes by reading the anchor row back and requiring its\n * own generated id to be the one there. The read-back lives here rather than\n * in the caller because a caller that trusted the generated id on a taken\n * anchor would point the address registry at a *phantom* account: the address\n * then resolves to nothing, and the opaque-anchor retry fails the same way.\n * Making the no-op detectable here is what keeps registration from stranding\n * an address forever.\n */\n public async createUser(input: { anchor: string; loginEmail: string; now: number }): Promise<string | null> {\n const anchor = input.anchor.toLowerCase();\n const loginEmail = input.loginEmail.toLowerCase();\n const id = newAccountId();\n await this.run('INSERT INTO users (email, created_at, id, current_email) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING', [\n anchor,\n input.now,\n id,\n loginEmail,\n ]);\n const row = await this.getByEmail(anchor);\n return row?.id === id ? id : null;\n }\n\n /**\n * @param idOrEmail The stable account key, or an anchor address as the\n * pre-identity fallback. `WHERE id = ? OR email = ?` cannot use either index\n * as a seek, so this stays off the hot path; login resolves through\n * `UserIdentityService` instead.\n */\n public async ensureUsername(idOrEmail: string, username: string, now: number): Promise<void> {\n await this.run('UPDATE users SET username = COALESCE(username, ?), updated_at = COALESCE(updated_at, ?) WHERE id = ? OR email = ?', [\n normalizeUsername(username),\n now,\n idOrEmail,\n idOrEmail,\n ]);\n }\n\n /** @param idOrEmail The stable account key, or an anchor address. */\n public async setUsername(idOrEmail: string, username: string, now: number): Promise<void> {\n await this.run('UPDATE users SET username = ?, updated_at = ? WHERE id = ? OR email = ?', [\n normalizeUsername(username),\n now,\n idOrEmail,\n idOrEmail,\n ]);\n }\n\n /**\n * Anchor lookup — used for legacy `*_email` values and cascade resolution.\n *\n * This is *not* a login lookup: an account that has changed its address is not\n * findable here by its new one. It is the pre-identity floor and the\n * attribution read.\n */\n public async getByEmail(email: string): Promise<UserRow | null> {\n return this.first<UserRow>('SELECT * FROM users WHERE email = ? LIMIT 1', [email.toLowerCase()]);\n }\n\n /** The account behind a stable key. Null on a pre-identity database. */\n public async getById(id: string): Promise<UserRow | null> {\n return this.first<UserRow>('SELECT * FROM users WHERE id = ? LIMIT 1', [id]);\n }\n\n /** The account currently signing in with this address. Identity-era only. */\n public async getByCurrentEmail(email: string): Promise<UserRow | null> {\n return this.first<UserRow>('SELECT * FROM users WHERE current_email = ? LIMIT 1', [email.toLowerCase()]);\n }\n\n /**\n * Move an account's sign-in address.\n *\n * The anchor `email` is deliberately untouched: it is what every legacy\n * `*_email` column and foreign key resolves against. Ordering matters — the\n * caller claims the new address first and revokes the old one third, so a\n * failure between steps never leaves the account without a way to sign in.\n */\n public async setCurrentEmail(id: string, email: string, now: number): Promise<void> {\n await this.run('UPDATE users SET current_email = ?, updated_at = ? WHERE id = ?', [email.toLowerCase(), now, id]);\n }\n\n /** Lookup by handle. */\n public async getByUsernameCi(usernameCi: string): Promise<UserRow | null> {\n return this.first<UserRow>('SELECT * FROM users WHERE username = ? LIMIT 1', [usernameCi.toLowerCase()]);\n }\n}\n\nexport { normalizeUsername };\nexport type { UserRow };\n"]}
|
package/dist/UserEmailDAO.d.ts
CHANGED
|
@@ -35,9 +35,9 @@ export declare class UserEmailDAO extends BaseDAO {
|
|
|
35
35
|
isVerified: boolean;
|
|
36
36
|
now: number;
|
|
37
37
|
}): Promise<EmailClaim>;
|
|
38
|
-
get(email: string): Promise<UserEmailRow |
|
|
38
|
+
get(email: string): Promise<UserEmailRow | null>;
|
|
39
39
|
/** Login resolution: only a verified address identifies an account. */
|
|
40
|
-
resolveVerified(email: string): Promise<UserEmailRow |
|
|
40
|
+
resolveVerified(email: string): Promise<UserEmailRow | null>;
|
|
41
41
|
listByUserId(userId: string): Promise<UserEmailRow[]>;
|
|
42
42
|
/** Revoke an address for login while keeping it resolvable for attribution. */
|
|
43
43
|
revoke(email: string): Promise<void>;
|
|
@@ -1 +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,
|
|
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,IAAI,CAAC;IAO7D,uEAAuE;IAC1D,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAI5D,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"}
|
package/dist/UserEmailDAO.js.map
CHANGED
|
@@ -1 +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","sourcesContent":["import { BaseDAO } from '@rexezuge/d1';\nimport type { D1Queryable } from '@rexezuge/d1';\n\n/**\n * One known address for an account.\n *\n * `is_verified` gates login: `1` means the address may authenticate the\n * account, `0` means it was changed away from and is retained only so rows\n * written before the change still resolve. A revoked row is re-pointed (not\n * deleted) when a later account legitimately claims the address, so an address\n * is never permanently reserved.\n *\n * Converged from `backend-data/src/dao/UserEmailDAO.ts` (identical in Durable-DAV,\n * Durable-DAV-Router, Edge-Git, Mail-Otter).\n */\nexport interface UserEmailRow {\n email: string;\n user_id: string;\n is_verified: number;\n created_at: number;\n}\n\nexport type EmailClaim = 'claimed' | 'already-claimed';\n\nexport class UserEmailDAO extends BaseDAO {\n public constructor(database: D1Queryable) {\n super(database);\n }\n\n /**\n * Claim an address for an account.\n *\n * An existing verified row is left alone: the address already belongs to\n * someone, and silently re-pointing it would hand one account's identity to\n * another. Callers check `resolveVerified` first and reject on a hit.\n * An unverified (revoked) row is re-pointed, which releases the address.\n */\n public async register(input: { email: string; userId: string; isVerified: boolean; now: number }): Promise<EmailClaim> {\n const email = input.email.toLowerCase();\n const existing = await this.get(email);\n if (existing && existing.is_verified === 1) return 'already-claimed';\n await this.run(\n `INSERT INTO user_emails (email, user_id, is_verified, created_at)\n VALUES (?, ?, ?, ?)\n ON CONFLICT(email) DO UPDATE SET user_id = excluded.user_id, is_verified = excluded.is_verified`,\n [email, input.userId, input.isVerified ? 1 : 0, input.now],\n );\n return 'claimed';\n }\n\n public async get(email: string): Promise<UserEmailRow |
|
|
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","sourcesContent":["import { BaseDAO } from '@rexezuge/d1';\nimport type { D1Queryable } from '@rexezuge/d1';\n\n/**\n * One known address for an account.\n *\n * `is_verified` gates login: `1` means the address may authenticate the\n * account, `0` means it was changed away from and is retained only so rows\n * written before the change still resolve. A revoked row is re-pointed (not\n * deleted) when a later account legitimately claims the address, so an address\n * is never permanently reserved.\n *\n * Converged from `backend-data/src/dao/UserEmailDAO.ts` (identical in Durable-DAV,\n * Durable-DAV-Router, Edge-Git, Mail-Otter).\n */\nexport interface UserEmailRow {\n email: string;\n user_id: string;\n is_verified: number;\n created_at: number;\n}\n\nexport type EmailClaim = 'claimed' | 'already-claimed';\n\nexport class UserEmailDAO extends BaseDAO {\n public constructor(database: D1Queryable) {\n super(database);\n }\n\n /**\n * Claim an address for an account.\n *\n * An existing verified row is left alone: the address already belongs to\n * someone, and silently re-pointing it would hand one account's identity to\n * another. Callers check `resolveVerified` first and reject on a hit.\n * An unverified (revoked) row is re-pointed, which releases the address.\n */\n public async register(input: { email: string; userId: string; isVerified: boolean; now: number }): Promise<EmailClaim> {\n const email = input.email.toLowerCase();\n const existing = await this.get(email);\n if (existing && existing.is_verified === 1) return 'already-claimed';\n await this.run(\n `INSERT INTO user_emails (email, user_id, is_verified, created_at)\n VALUES (?, ?, ?, ?)\n ON CONFLICT(email) DO UPDATE SET user_id = excluded.user_id, is_verified = excluded.is_verified`,\n [email, input.userId, input.isVerified ? 1 : 0, input.now],\n );\n return 'claimed';\n }\n\n public async get(email: string): Promise<UserEmailRow | null> {\n // Lowercase the *parameter*: the column is stored lowercased by every\n // writer, so `lower(email) = ?` matched identically while making the\n // primary key unusable.\n return this.first<UserEmailRow>('SELECT * FROM user_emails WHERE email = ? LIMIT 1', [email.toLowerCase()]);\n }\n\n /** Login resolution: only a verified address identifies an account. */\n public async resolveVerified(email: string): Promise<UserEmailRow | null> {\n return this.first<UserEmailRow>('SELECT * FROM user_emails WHERE email = ? AND is_verified = 1 LIMIT 1', [email.toLowerCase()]);\n }\n\n public async listByUserId(userId: string): Promise<UserEmailRow[]> {\n return this.all<UserEmailRow>('SELECT * FROM user_emails WHERE user_id = ? ORDER BY is_verified DESC, created_at ASC', [userId]);\n }\n\n /** Revoke an address for login while keeping it resolvable for attribution. */\n public async revoke(email: string): Promise<void> {\n await this.run('UPDATE user_emails SET is_verified = 0 WHERE email = ?', [email.toLowerCase()]);\n }\n\n /**\n * Revoke every verified address for an account. Used when an account's login\n * address changes, so only the new address can authenticate it.\n */\n public async revokeAllVerified(userId: string, exceptEmail: string): Promise<void> {\n await this.run('UPDATE user_emails SET is_verified = 0 WHERE user_id = ? AND email != ?', [userId, exceptEmail.toLowerCase()]);\n }\n}\n"]}
|
|
@@ -1 +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,
|
|
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,CA+C5G;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"}
|
package/dist/accountLookup.js
CHANGED
|
@@ -31,6 +31,11 @@ export async function resolveAccount(deps, email) {
|
|
|
31
31
|
//
|
|
32
32
|
// So the degradation is narrowed to `isMissingSchemaError`, and everything
|
|
33
33
|
// else rethrows.
|
|
34
|
+
// The `null` in the `catch` answers the same question the DAO's own `null`
|
|
35
|
+
// answers — no row — so the two branches below share one type. When this read
|
|
36
|
+
// returned `undefined` on success and `null` on a missing schema, the widened
|
|
37
|
+
// union had to be narrowed by truthiness at a point that had nothing to do
|
|
38
|
+
// with which branch it came from.
|
|
34
39
|
const registered = await deps
|
|
35
40
|
.userEmailDAO()
|
|
36
41
|
.then((dao) => dao.get(normalized))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"accountLookup.js","sourceRoot":"","sources":["../src/accountLookup.ts"],"names":[],"mappings":"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","sourcesContent":["import { isMissingSchemaError } from '@rexezuge/d1';\nimport { summarize } from './ResolvedAccount';\nimport type { ResolvedAccount } from './ResolvedAccount';\nimport { UserDAO } from './UserDAO';\nimport { UserEmailDAO } from './UserEmailDAO';\n\n/**\n * Injected DAO factories. All optional: each defaults to a real DAO over the\n * same D1 binding, so tests override only what they need.\n */\nexport interface AccountLookupDeps {\n userDAO: () => Promise<UserDAO>;\n userEmailDAO: () => Promise<UserEmailDAO>;\n}\n\n/**\n * Address -> account resolution, as free functions so the service and any\n * higher-level account bootstrap share one implementation instead of\n * re-deriving the query.\n *\n * Converged from `backend-services/src/user/accountLookup.ts` (identical in\n * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter).\n */\nexport async function resolveAccount(deps: AccountLookupDeps, email: string): Promise<ResolvedAccount | null> {\n const normalized = email.trim().toLowerCase();\n if (!normalized) return null;\n const userDAO = await deps.userDAO();\n // A known address is authoritative: the registry says which account it belongs\n // to, and a *revoked* row (the account moved off this address) must not\n // resolve at all. Falling through to the legacy lookups in that case would let\n // a reassigned address keep authenticating the previous holder's account.\n //\n // Only a *missing registry* falls through, and \"missing\" means one specific\n // thing: the identity migration has not run. The previous\n // `.catch(() => null)` answered that question for **every** failure, so a\n // transient D1 error on this one statement made the code proceed to the anchor\n // lookup below — `getByEmail`, which is an attribution lookup whose key is a\n // re-registrable string, not an identity. An address whose account had moved\n // off it (so its `user_emails` row is revoked, and correctly refuses to\n // resolve) would then be resolved against the *previous holder's* anchor, and\n // that account's id was installed as the caller's identity: full access to\n // someone else's rows from one failed `SELECT`.\n //\n // So the degradation is narrowed to `isMissingSchemaError`, and everything\n // else rethrows.\n const registered = await deps\n .userEmailDAO()\n .then((dao) => dao.get(normalized))\n .catch((error: unknown) => {\n if (isMissingSchemaError(error)) return null;\n throw error;\n });\n if (registered) {\n return registered.is_verified === 1 ? summarize(await userDAO.getById(registered.user_id)) : null;\n }\n // No registry row at all. Safe to fall through: the identity migration\n // backfilled a verified row for every anchor, so a row-less address is either\n // new or on a pre-identity database, where the address *is* the anchor.\n //\n // No `.catch` on either: a failed `users` read is an outage, and reporting it\n // as \"no such account\" would turn a D1 blip into a 404 and a spurious\n // re-registration attempt.\n const byCurrent = await userDAO.getByCurrentEmail(normalized);\n const legacy = byCurrent ?? (await userDAO.getByEmail(normalized));\n return summarize(legacy);\n}\n\n/** Account id for a sign-in address, or null when unknown. */\nexport async function resolveUserId(deps: AccountLookupDeps, email: string): Promise<string | null> {\n const account = await resolveAccount(deps, email);\n return account?.id ?? null;\n}\n\n/**\n * Insert an account and claim its sign-in address.\n *\n * The anchor is the address itself whenever it is free, which keeps new rows\n * shaped like the pre-identity ones. It falls back to an opaque anchor only when\n * the address is already held as another account's anchor — i.e. its previous\n * holder moved off it — so a released address is never permanently unusable.\n *\n * Returns null when the insert could not produce a *new* account holding this\n * address, which is the caller's signal to retry with a different anchor.\n *\n * The read-back is load-bearing, not defensive. `createUser` is\n * `ON CONFLICT(email) DO NOTHING`, so when the anchor is already taken the\n * insert is silently a no-op and the generated `id` belongs to nobody. Trusting\n * it would (a) re-point the address registry at a phantom account that resolves\n * to nothing, permanently stranding the address, and (b) on the opaque-anchor\n * retry, hand the caller an account whose address the registry refuses to\n * re-point because the phantom row already marked it verified. Re-reading the\n * anchor row and requiring *our* id to be the one there makes the no-op\n * detectable, so the retry gets a genuinely new account.\n */\nexport async function registerAccount(\n deps: AccountLookupDeps,\n loginEmail: string,\n anchor: string,\n now: number,\n): Promise<ResolvedAccount | null> {\n const userDAO = await deps.userDAO();\n const created = await userDAO.createUser({ anchor, loginEmail, now });\n // `null` is the detected no-op: the anchor was already held. The caller's\n // signal to retry with an opaque anchor — see `newAnchor`.\n if (created === null) return null;\n // Claim the sign-in address *before* resolving, otherwise the fresh account is\n // invisible to the registry and registration looks like a failure. An address\n // already verified for another account is left alone by `register`, and the\n // resolve below then reports that account instead.\n await deps\n .userEmailDAO()\n .then((dao) => dao.register({ email: loginEmail, userId: created, isVerified: true, now }))\n .catch((error: unknown) => {\n // Pre-identity registry only, per the same rule `resolveAccount` follows: a\n // missing table is tolerated, a failed write is not. Swallowing the second\n // would return an account the address registry has never heard of, which\n // resolves to nothing on the next login.\n if (isMissingSchemaError(error)) return;\n throw error;\n });\n return resolveAccount(deps, loginEmail);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"accountLookup.js","sourceRoot":"","sources":["../src/accountLookup.ts"],"names":[],"mappings":"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","sourcesContent":["import { isMissingSchemaError } from '@rexezuge/d1';\nimport { summarize } from './ResolvedAccount';\nimport type { ResolvedAccount } from './ResolvedAccount';\nimport { UserDAO } from './UserDAO';\nimport { UserEmailDAO } from './UserEmailDAO';\n\n/**\n * Injected DAO factories. All optional: each defaults to a real DAO over the\n * same D1 binding, so tests override only what they need.\n */\nexport interface AccountLookupDeps {\n userDAO: () => Promise<UserDAO>;\n userEmailDAO: () => Promise<UserEmailDAO>;\n}\n\n/**\n * Address -> account resolution, as free functions so the service and any\n * higher-level account bootstrap share one implementation instead of\n * re-deriving the query.\n *\n * Converged from `backend-services/src/user/accountLookup.ts` (identical in\n * Durable-DAV, Durable-DAV-Router, Edge-Git, Mail-Otter).\n */\nexport async function resolveAccount(deps: AccountLookupDeps, email: string): Promise<ResolvedAccount | null> {\n const normalized = email.trim().toLowerCase();\n if (!normalized) return null;\n const userDAO = await deps.userDAO();\n // A known address is authoritative: the registry says which account it belongs\n // to, and a *revoked* row (the account moved off this address) must not\n // resolve at all. Falling through to the legacy lookups in that case would let\n // a reassigned address keep authenticating the previous holder's account.\n //\n // Only a *missing registry* falls through, and \"missing\" means one specific\n // thing: the identity migration has not run. The previous\n // `.catch(() => null)` answered that question for **every** failure, so a\n // transient D1 error on this one statement made the code proceed to the anchor\n // lookup below — `getByEmail`, which is an attribution lookup whose key is a\n // re-registrable string, not an identity. An address whose account had moved\n // off it (so its `user_emails` row is revoked, and correctly refuses to\n // resolve) would then be resolved against the *previous holder's* anchor, and\n // that account's id was installed as the caller's identity: full access to\n // someone else's rows from one failed `SELECT`.\n //\n // So the degradation is narrowed to `isMissingSchemaError`, and everything\n // else rethrows.\n // The `null` in the `catch` answers the same question the DAO's own `null`\n // answers — no row — so the two branches below share one type. When this read\n // returned `undefined` on success and `null` on a missing schema, the widened\n // union had to be narrowed by truthiness at a point that had nothing to do\n // with which branch it came from.\n const registered = await deps\n .userEmailDAO()\n .then((dao) => dao.get(normalized))\n .catch((error: unknown) => {\n if (isMissingSchemaError(error)) return null;\n throw error;\n });\n if (registered) {\n return registered.is_verified === 1 ? summarize(await userDAO.getById(registered.user_id)) : null;\n }\n // No registry row at all. Safe to fall through: the identity migration\n // backfilled a verified row for every anchor, so a row-less address is either\n // new or on a pre-identity database, where the address *is* the anchor.\n //\n // No `.catch` on either: a failed `users` read is an outage, and reporting it\n // as \"no such account\" would turn a D1 blip into a 404 and a spurious\n // re-registration attempt.\n const byCurrent = await userDAO.getByCurrentEmail(normalized);\n const legacy = byCurrent ?? (await userDAO.getByEmail(normalized));\n return summarize(legacy);\n}\n\n/** Account id for a sign-in address, or null when unknown. */\nexport async function resolveUserId(deps: AccountLookupDeps, email: string): Promise<string | null> {\n const account = await resolveAccount(deps, email);\n return account?.id ?? null;\n}\n\n/**\n * Insert an account and claim its sign-in address.\n *\n * The anchor is the address itself whenever it is free, which keeps new rows\n * shaped like the pre-identity ones. It falls back to an opaque anchor only when\n * the address is already held as another account's anchor — i.e. its previous\n * holder moved off it — so a released address is never permanently unusable.\n *\n * Returns null when the insert could not produce a *new* account holding this\n * address, which is the caller's signal to retry with a different anchor.\n *\n * The read-back is load-bearing, not defensive. `createUser` is\n * `ON CONFLICT(email) DO NOTHING`, so when the anchor is already taken the\n * insert is silently a no-op and the generated `id` belongs to nobody. Trusting\n * it would (a) re-point the address registry at a phantom account that resolves\n * to nothing, permanently stranding the address, and (b) on the opaque-anchor\n * retry, hand the caller an account whose address the registry refuses to\n * re-point because the phantom row already marked it verified. Re-reading the\n * anchor row and requiring *our* id to be the one there makes the no-op\n * detectable, so the retry gets a genuinely new account.\n */\nexport async function registerAccount(\n deps: AccountLookupDeps,\n loginEmail: string,\n anchor: string,\n now: number,\n): Promise<ResolvedAccount | null> {\n const userDAO = await deps.userDAO();\n const created = await userDAO.createUser({ anchor, loginEmail, now });\n // `null` is the detected no-op: the anchor was already held. The caller's\n // signal to retry with an opaque anchor — see `newAnchor`.\n if (created === null) return null;\n // Claim the sign-in address *before* resolving, otherwise the fresh account is\n // invisible to the registry and registration looks like a failure. An address\n // already verified for another account is left alone by `register`, and the\n // resolve below then reports that account instead.\n await deps\n .userEmailDAO()\n .then((dao) => dao.register({ email: loginEmail, userId: created, isVerified: true, now }))\n .catch((error: unknown) => {\n // Pre-identity registry only, per the same rule `resolveAccount` follows: a\n // missing table is tolerated, a failed write is not. Swallowing the second\n // would return an account the address registry has never heard of, which\n // resolves to nothing on the next login.\n if (isMissingSchemaError(error)) return;\n throw error;\n });\n return resolveAccount(deps, loginEmail);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rexezuge/identity",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"dist"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@rexezuge/errors": "1.0
|
|
29
|
-
"@rexezuge/shared": "1.0
|
|
30
|
-
"@rexezuge/d1": "1.0
|
|
28
|
+
"@rexezuge/errors": "1.1.0",
|
|
29
|
+
"@rexezuge/shared": "1.1.0",
|
|
30
|
+
"@rexezuge/d1": "1.1.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "26.6.4",
|