@axiom-lattice/pg-stores 5.0.2 → 5.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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +102 -0
  3. package/dist/index.d.mts +178 -2
  4. package/dist/index.d.ts +178 -2
  5. package/dist/index.js +1243 -197
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1167 -124
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/PostgreSQLInviteLinkStore.test.ts +63 -0
  11. package/src/__tests__/PostgreSQLOidcIdentityClaimStore.test.ts +123 -0
  12. package/src/__tests__/PostgreSQLOidcProviderConfigStore.test.ts +233 -0
  13. package/src/__tests__/PostgreSQLPlatformInvitationStore.test.ts +222 -0
  14. package/src/__tests__/PostgreSQLPlatformMemberStore.test.ts +146 -0
  15. package/src/__tests__/PostgreSQLPlatformSettingsStore.test.ts +115 -0
  16. package/src/__tests__/PostgreSQLUserExternalIdentityStore.test.ts +125 -0
  17. package/src/__tests__/PostgreSQLUserStore.test.ts +75 -0
  18. package/src/createPgStoreConfig.ts +34 -0
  19. package/src/index.ts +16 -0
  20. package/src/migrations/invite_link_migration.ts +35 -0
  21. package/src/migrations/oidc_identity_claim_invite_migration.ts +29 -0
  22. package/src/migrations/oidc_identity_claim_migration.ts +39 -0
  23. package/src/migrations/oidc_provider_allowed_hosts_migration.ts +19 -0
  24. package/src/migrations/oidc_provider_config_migration.ts +27 -0
  25. package/src/migrations/platform_invitation_migration.ts +22 -0
  26. package/src/migrations/platform_member_migration.ts +21 -0
  27. package/src/migrations/platform_settings_migration.ts +21 -0
  28. package/src/migrations/user_email_lower_unique_migration.ts +22 -0
  29. package/src/migrations/user_external_identity_migration.ts +27 -0
  30. package/src/stores/PostgreSQLInviteLinkStore.ts +134 -0
  31. package/src/stores/PostgreSQLOidcIdentityClaimStore.ts +251 -0
  32. package/src/stores/PostgreSQLOidcProviderConfigStore.ts +222 -0
  33. package/src/stores/PostgreSQLPlatformInvitationStore.ts +133 -0
  34. package/src/stores/PostgreSQLPlatformMemberStore.ts +105 -0
  35. package/src/stores/PostgreSQLPlatformSettingsStore.ts +98 -0
  36. package/src/stores/PostgreSQLUserExternalIdentityStore.ts +134 -0
  37. package/src/stores/PostgreSQLUserStore.ts +3 -2
@@ -0,0 +1,222 @@
1
+ import { safeDecrypt, encrypt } from "@axiom-lattice/core";
2
+ import {
3
+ OidcProviderConfigConflictError,
4
+ type CreateOidcProviderConfigInput,
5
+ type OidcProviderConfig,
6
+ type OidcProviderConfigStore,
7
+ type UpdateOidcProviderConfigInput,
8
+ } from "@axiom-lattice/protocols";
9
+ import type { Pool } from "pg";
10
+
11
+ const TABLE = "lattice_oidc_provider_configs";
12
+
13
+ interface ProviderRow {
14
+ id: string;
15
+ display_name: string;
16
+ issuer: string;
17
+ client_id: string;
18
+ client_secret: string;
19
+ scopes: string;
20
+ enabled: boolean;
21
+ jit_provisioning: boolean;
22
+ auto_link_email: boolean;
23
+ default_tenant_id: string | null;
24
+ allowed_private_hosts: string[] | null;
25
+ created_at: Date;
26
+ updated_at: Date;
27
+ }
28
+
29
+ function parseAllowedPrivateHosts(value: unknown): string[] | undefined {
30
+ if (!Array.isArray(value)) return undefined;
31
+ return value.filter((entry): entry is string => typeof entry === "string");
32
+ }
33
+
34
+ function serializeAllowedPrivateHosts(
35
+ value: readonly string[] | undefined,
36
+ ): string | null {
37
+ return value === undefined ? null : JSON.stringify(value);
38
+ }
39
+
40
+ function normalizeTenantId(value: string | null | undefined): string | undefined {
41
+ if (typeof value !== "string") return undefined;
42
+ const trimmed = value.trim();
43
+ return trimmed.length > 0 ? trimmed : undefined;
44
+ }
45
+
46
+ function mapRow(row: ProviderRow): OidcProviderConfig {
47
+ if (
48
+ typeof row.id !== "string" ||
49
+ typeof row.display_name !== "string" ||
50
+ typeof row.issuer !== "string" ||
51
+ typeof row.client_id !== "string" ||
52
+ typeof row.client_secret !== "string" ||
53
+ typeof row.scopes !== "string" ||
54
+ typeof row.enabled !== "boolean" ||
55
+ typeof row.jit_provisioning !== "boolean" ||
56
+ typeof row.auto_link_email !== "boolean" ||
57
+ !(row.created_at instanceof Date) ||
58
+ !(row.updated_at instanceof Date)
59
+ ) {
60
+ throw new Error("Invalid OIDC provider config row");
61
+ }
62
+ const allowedPrivateHosts = parseAllowedPrivateHosts(
63
+ row.allowed_private_hosts,
64
+ );
65
+ return {
66
+ id: row.id,
67
+ displayName: row.display_name,
68
+ issuer: row.issuer,
69
+ clientId: row.client_id,
70
+ clientSecret: safeDecrypt(
71
+ row.client_secret,
72
+ `OIDC provider config "${row.id}" client secret`,
73
+ ),
74
+ scopes: row.scopes,
75
+ enabled: row.enabled,
76
+ jitProvisioning: row.jit_provisioning,
77
+ autoLinkEmail: row.auto_link_email,
78
+ ...(typeof row.default_tenant_id === "string"
79
+ ? { defaultTenantId: row.default_tenant_id }
80
+ : {}),
81
+ ...(allowedPrivateHosts !== undefined ? { allowedPrivateHosts } : {}),
82
+ createdAt: row.created_at,
83
+ updatedAt: row.updated_at,
84
+ };
85
+ }
86
+
87
+ function isUniqueViolation(error: unknown): boolean {
88
+ return (
89
+ typeof error === "object" &&
90
+ error !== null &&
91
+ "code" in error &&
92
+ (error as { code?: unknown }).code === "23505"
93
+ );
94
+ }
95
+
96
+ /**
97
+ * PostgreSQL implementation of {@link OidcProviderConfigStore}.
98
+ *
99
+ * Persists OIDC provider configurations in `lattice_oidc_provider_configs`,
100
+ * encrypting the client secret at rest and using the database clock for
101
+ * `updated_at` on writes.
102
+ */
103
+ export class PostgreSQLOidcProviderConfigStore implements OidcProviderConfigStore {
104
+ private readonly pool: Pool;
105
+
106
+ constructor(options: { pool: Pool }) {
107
+ this.pool = options.pool;
108
+ }
109
+
110
+ async list(): Promise<OidcProviderConfig[]> {
111
+ const result = await this.pool.query<ProviderRow>(
112
+ `SELECT * FROM ${TABLE} ORDER BY created_at ASC, id ASC`,
113
+ );
114
+ return result.rows.map(mapRow);
115
+ }
116
+
117
+ async get(id: string): Promise<OidcProviderConfig | null> {
118
+ const result = await this.pool.query<ProviderRow>(
119
+ `SELECT * FROM ${TABLE} WHERE id = $1`,
120
+ [id],
121
+ );
122
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
123
+ }
124
+
125
+ async create(
126
+ id: string,
127
+ input: CreateOidcProviderConfigInput,
128
+ ): Promise<OidcProviderConfig> {
129
+ const tenantId = normalizeTenantId(input.defaultTenantId);
130
+ try {
131
+ await this.pool.query(
132
+ `INSERT INTO ${TABLE}
133
+ (id, display_name, issuer, client_id, client_secret, scopes,
134
+ enabled, jit_provisioning, auto_link_email, default_tenant_id,
135
+ allowed_private_hosts)
136
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
137
+ [
138
+ id,
139
+ input.displayName,
140
+ input.issuer,
141
+ input.clientId,
142
+ encrypt(input.clientSecret),
143
+ input.scopes,
144
+ input.enabled,
145
+ input.jitProvisioning,
146
+ input.autoLinkEmail,
147
+ tenantId ?? null,
148
+ serializeAllowedPrivateHosts(input.allowedPrivateHosts),
149
+ ],
150
+ );
151
+ } catch (error) {
152
+ if (isUniqueViolation(error)) {
153
+ throw new OidcProviderConfigConflictError(id);
154
+ }
155
+ throw error;
156
+ }
157
+
158
+ const created = await this.get(id);
159
+ if (!created) {
160
+ throw new Error(`OIDC provider config "${id}" was not persisted`);
161
+ }
162
+ return created;
163
+ }
164
+
165
+ async update(
166
+ id: string,
167
+ updates: UpdateOidcProviderConfigInput,
168
+ ): Promise<OidcProviderConfig | null> {
169
+ const existing = await this.get(id);
170
+ if (!existing) return null;
171
+
172
+ const setClauses: string[] = [];
173
+ const values: unknown[] = [];
174
+ let paramIndex = 1;
175
+
176
+ const set = (column: string, value: unknown): void => {
177
+ setClauses.push(`${column} = $${paramIndex++}`);
178
+ values.push(value);
179
+ };
180
+
181
+ if (updates.displayName !== undefined) set("display_name", updates.displayName);
182
+ if (updates.issuer !== undefined) set("issuer", updates.issuer);
183
+ if (updates.clientId !== undefined) set("client_id", updates.clientId);
184
+ if (updates.clientSecret !== undefined && updates.clientSecret !== "") {
185
+ set("client_secret", encrypt(updates.clientSecret));
186
+ }
187
+ if (updates.scopes !== undefined) set("scopes", updates.scopes);
188
+ if (updates.enabled !== undefined) set("enabled", updates.enabled);
189
+ if (updates.jitProvisioning !== undefined) {
190
+ set("jit_provisioning", updates.jitProvisioning);
191
+ }
192
+ if (updates.autoLinkEmail !== undefined) {
193
+ set("auto_link_email", updates.autoLinkEmail);
194
+ }
195
+ if (updates.defaultTenantId !== undefined) {
196
+ set("default_tenant_id", normalizeTenantId(updates.defaultTenantId) ?? null);
197
+ }
198
+ if (updates.allowedPrivateHosts !== undefined) {
199
+ set(
200
+ "allowed_private_hosts",
201
+ serializeAllowedPrivateHosts(updates.allowedPrivateHosts),
202
+ );
203
+ }
204
+
205
+ if (setClauses.length === 0) return existing;
206
+
207
+ setClauses.push("updated_at = NOW()");
208
+ values.push(id);
209
+
210
+ await this.pool.query(
211
+ `UPDATE ${TABLE} SET ${setClauses.join(", ")} WHERE id = $${paramIndex}`,
212
+ values,
213
+ );
214
+
215
+ return this.get(id);
216
+ }
217
+
218
+ async delete(id: string): Promise<boolean> {
219
+ const result = await this.pool.query(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
220
+ return (result.rowCount ?? 0) > 0;
221
+ }
222
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ normalizePlatformInvitationEmail,
3
+ type PlatformInvitation,
4
+ type PlatformInvitationStore,
5
+ type PlatformRole,
6
+ } from "@axiom-lattice/protocols";
7
+ import type { Pool } from "pg";
8
+
9
+ const TABLE = "lattice_platform_invitations";
10
+ const ROLES: readonly PlatformRole[] = ["owner", "admin", "auditor"];
11
+
12
+ interface InvitationRow {
13
+ email: string;
14
+ role: string;
15
+ invited_by: string | null;
16
+ created_at: Date;
17
+ expires_at: Date | null;
18
+ consumed_at: Date | null;
19
+ }
20
+
21
+ function isPlatformRole(value: string): value is PlatformRole {
22
+ return (ROLES as readonly string[]).includes(value);
23
+ }
24
+
25
+ function mapRow(row: InvitationRow): PlatformInvitation {
26
+ if (
27
+ typeof row.email !== "string" ||
28
+ typeof row.role !== "string" ||
29
+ !isPlatformRole(row.role) ||
30
+ !(row.created_at instanceof Date)
31
+ ) {
32
+ throw new Error("Invalid platform invitation row");
33
+ }
34
+ return {
35
+ email: row.email,
36
+ role: row.role,
37
+ ...(typeof row.invited_by === "string" ? { invitedBy: row.invited_by } : {}),
38
+ createdAt: row.created_at,
39
+ ...(row.expires_at instanceof Date ? { expiresAt: row.expires_at } : {}),
40
+ ...(row.consumed_at instanceof Date ? { consumedAt: row.consumed_at } : {}),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * PostgreSQL implementation of {@link PlatformInvitationStore}.
46
+ *
47
+ * Persists invitations in `lattice_platform_invitations`, keyed by normalized
48
+ * email.
49
+ */
50
+ export class PostgreSQLPlatformInvitationStore
51
+ implements PlatformInvitationStore
52
+ {
53
+ private readonly pool: Pool;
54
+
55
+ constructor(options: { pool: Pool }) {
56
+ this.pool = options.pool;
57
+ }
58
+
59
+ /** Lists every invitation ordered by normalized email. */
60
+ async list(): Promise<PlatformInvitation[]> {
61
+ const result = await this.pool.query<InvitationRow>(
62
+ `SELECT * FROM ${TABLE} ORDER BY email ASC`,
63
+ );
64
+ return result.rows.map(mapRow);
65
+ }
66
+
67
+ /** Returns the invitation for an email, or null. */
68
+ async get(email: string): Promise<PlatformInvitation | null> {
69
+ const result = await this.pool.query<InvitationRow>(
70
+ `SELECT * FROM ${TABLE} WHERE email = $1`,
71
+ [normalizePlatformInvitationEmail(email)],
72
+ );
73
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
74
+ }
75
+
76
+ /**
77
+ * Inserts or replaces an invitation keyed by normalized email.
78
+ *
79
+ * The supplied invitation replaces any existing row in full (last write wins),
80
+ * including `expiresAt` and `consumedAt`.
81
+ */
82
+ async upsert(invitation: PlatformInvitation): Promise<PlatformInvitation> {
83
+ const result = await this.pool.query<InvitationRow>(
84
+ `INSERT INTO ${TABLE}
85
+ (email, role, invited_by, created_at, expires_at, consumed_at)
86
+ VALUES ($1, $2, $3, $4, $5, $6)
87
+ ON CONFLICT (email) DO UPDATE SET
88
+ role = EXCLUDED.role,
89
+ invited_by = EXCLUDED.invited_by,
90
+ created_at = EXCLUDED.created_at,
91
+ expires_at = EXCLUDED.expires_at,
92
+ consumed_at = EXCLUDED.consumed_at
93
+ RETURNING *`,
94
+ [
95
+ normalizePlatformInvitationEmail(invitation.email),
96
+ invitation.role,
97
+ invitation.invitedBy ?? null,
98
+ invitation.createdAt,
99
+ invitation.expiresAt ?? null,
100
+ invitation.consumedAt ?? null,
101
+ ],
102
+ );
103
+ return mapRow(result.rows[0]);
104
+ }
105
+
106
+ /** Deletes an invitation. Returns whether a row was removed. */
107
+ async delete(email: string): Promise<boolean> {
108
+ const result = await this.pool.query(
109
+ `DELETE FROM ${TABLE} WHERE email = $1`,
110
+ [normalizePlatformInvitationEmail(email)],
111
+ );
112
+ return (result.rowCount ?? 0) > 0;
113
+ }
114
+
115
+ /**
116
+ * Atomically consumes a pending, unexpired invitation.
117
+ *
118
+ * Returns the consumed invitation, or null when the row is missing, already
119
+ * consumed, or expired.
120
+ */
121
+ async consume(email: string, at: Date): Promise<PlatformInvitation | null> {
122
+ const result = await this.pool.query<InvitationRow>(
123
+ `UPDATE ${TABLE}
124
+ SET consumed_at = $1
125
+ WHERE email = $2
126
+ AND consumed_at IS NULL
127
+ AND (expires_at IS NULL OR expires_at > $1)
128
+ RETURNING *`,
129
+ [at, normalizePlatformInvitationEmail(email)],
130
+ );
131
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
132
+ }
133
+ }
@@ -0,0 +1,105 @@
1
+ import type {
2
+ PlatformMember,
3
+ PlatformMemberStore,
4
+ PlatformRole,
5
+ } from "@axiom-lattice/protocols";
6
+ import type { Pool } from "pg";
7
+
8
+ const TABLE = "lattice_platform_members";
9
+ const ROLES: readonly PlatformRole[] = ["owner", "admin", "auditor"];
10
+
11
+ interface MemberRow {
12
+ user_id: string;
13
+ role: string;
14
+ granted_by: string | null;
15
+ granted_at: Date;
16
+ updated_at: Date;
17
+ }
18
+
19
+ function isPlatformRole(value: string): value is PlatformRole {
20
+ return (ROLES as readonly string[]).includes(value);
21
+ }
22
+
23
+ function mapRow(row: MemberRow): PlatformMember {
24
+ if (
25
+ typeof row.user_id !== "string" ||
26
+ typeof row.role !== "string" ||
27
+ !isPlatformRole(row.role) ||
28
+ !(row.granted_at instanceof Date) ||
29
+ !(row.updated_at instanceof Date)
30
+ ) {
31
+ throw new Error("Invalid platform member row");
32
+ }
33
+ return {
34
+ userId: row.user_id,
35
+ role: row.role,
36
+ ...(typeof row.granted_by === "string" ? { grantedBy: row.granted_by } : {}),
37
+ grantedAt: row.granted_at,
38
+ updatedAt: row.updated_at,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * PostgreSQL implementation of {@link PlatformMemberStore}.
44
+ *
45
+ * Persists platform grants in `lattice_platform_members`, keyed by user id.
46
+ */
47
+ export class PostgreSQLPlatformMemberStore implements PlatformMemberStore {
48
+ private readonly pool: Pool;
49
+
50
+ constructor(options: { pool: Pool }) {
51
+ this.pool = options.pool;
52
+ }
53
+
54
+ /** Lists every platform member ordered by user id. */
55
+ async list(): Promise<PlatformMember[]> {
56
+ const result = await this.pool.query<MemberRow>(
57
+ `SELECT * FROM ${TABLE} ORDER BY user_id ASC`,
58
+ );
59
+ return result.rows.map(mapRow);
60
+ }
61
+
62
+ /** Returns the platform member for a user, or null. */
63
+ async get(userId: string): Promise<PlatformMember | null> {
64
+ const result = await this.pool.query<MemberRow>(
65
+ `SELECT * FROM ${TABLE} WHERE user_id = $1`,
66
+ [userId],
67
+ );
68
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
69
+ }
70
+
71
+ /**
72
+ * Inserts or replaces a member keyed by user id.
73
+ *
74
+ * The supplied grant replaces any existing row in full (last write wins),
75
+ * including `grantedAt` and `updatedAt`.
76
+ */
77
+ async upsert(member: PlatformMember): Promise<PlatformMember> {
78
+ const result = await this.pool.query<MemberRow>(
79
+ `INSERT INTO ${TABLE} (user_id, role, granted_by, granted_at, updated_at)
80
+ VALUES ($1, $2, $3, $4, $5)
81
+ ON CONFLICT (user_id) DO UPDATE SET
82
+ role = EXCLUDED.role,
83
+ granted_by = EXCLUDED.granted_by,
84
+ granted_at = EXCLUDED.granted_at,
85
+ updated_at = EXCLUDED.updated_at
86
+ RETURNING *`,
87
+ [
88
+ member.userId,
89
+ member.role,
90
+ member.grantedBy ?? null,
91
+ member.grantedAt,
92
+ member.updatedAt,
93
+ ],
94
+ );
95
+ return mapRow(result.rows[0]);
96
+ }
97
+
98
+ /** Deletes a member. Returns whether a row was removed. */
99
+ async delete(userId: string): Promise<boolean> {
100
+ const result = await this.pool.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [
101
+ userId,
102
+ ]);
103
+ return (result.rowCount ?? 0) > 0;
104
+ }
105
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ PlatformSettingsVersionConflictError,
3
+ type PlatformSettingsDocument,
4
+ type PlatformSettingsRecord,
5
+ type PlatformSettingsStore,
6
+ } from "@axiom-lattice/protocols";
7
+ import type { Pool } from "pg";
8
+
9
+ const TABLE = "lattice_platform_settings";
10
+ const SETTINGS_ID = "default";
11
+ const COLUMNS = "id, document, version, updated_by, updated_at";
12
+
13
+ function normalizeUpdatedBy(updatedBy?: string): string | null {
14
+ const trimmed = updatedBy?.trim();
15
+ return trimmed ? trimmed : null;
16
+ }
17
+
18
+ interface SettingsRow {
19
+ id: string;
20
+ document: PlatformSettingsDocument;
21
+ version: number;
22
+ updated_by: string | null;
23
+ updated_at: Date;
24
+ }
25
+
26
+ function mapRow(row: SettingsRow): PlatformSettingsRecord {
27
+ return {
28
+ ...row.document,
29
+ version: Number(row.version),
30
+ ...(row.updated_by !== null ? { updatedBy: row.updated_by } : {}),
31
+ updatedAt: row.updated_at,
32
+ };
33
+ }
34
+
35
+ /** PostgreSQL implementation of {@link PlatformSettingsStore}. */
36
+ export class PostgreSQLPlatformSettingsStore implements PlatformSettingsStore {
37
+ constructor(private readonly options: { pool: Pool }) {}
38
+
39
+ private get pool(): Pool {
40
+ return this.options.pool;
41
+ }
42
+
43
+ async get(): Promise<PlatformSettingsRecord | null> {
44
+ const result = await this.pool.query<SettingsRow>(
45
+ `SELECT ${COLUMNS} FROM ${TABLE} WHERE id = $1`,
46
+ [SETTINGS_ID],
47
+ );
48
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
49
+ }
50
+
51
+ async save(
52
+ document: PlatformSettingsDocument,
53
+ expectedVersion: number,
54
+ updatedBy?: string,
55
+ ): Promise<PlatformSettingsRecord> {
56
+ const version = expectedVersion + 1;
57
+ const result =
58
+ expectedVersion === 0
59
+ ? await this.pool.query<SettingsRow>(
60
+ `INSERT INTO ${TABLE} (id, document, version, updated_by, updated_at)
61
+ VALUES ($1, $2::jsonb, $3, $4, NOW())
62
+ ON CONFLICT (id) DO NOTHING
63
+ RETURNING ${COLUMNS}`,
64
+ [
65
+ SETTINGS_ID,
66
+ JSON.stringify(document),
67
+ version,
68
+ normalizeUpdatedBy(updatedBy),
69
+ ],
70
+ )
71
+ : await this.pool.query<SettingsRow>(
72
+ `UPDATE ${TABLE}
73
+ SET document = $2::jsonb, version = $3, updated_by = $4, updated_at = NOW()
74
+ WHERE id = $1 AND version = $5
75
+ RETURNING ${COLUMNS}`,
76
+ [
77
+ SETTINGS_ID,
78
+ JSON.stringify(document),
79
+ version,
80
+ normalizeUpdatedBy(updatedBy),
81
+ expectedVersion,
82
+ ],
83
+ );
84
+
85
+ if (result.rows[0]) {
86
+ return mapRow(result.rows[0]);
87
+ }
88
+
89
+ const actual = await this.pool.query<{ version: number }>(
90
+ `SELECT version FROM ${TABLE} WHERE id = $1`,
91
+ [SETTINGS_ID],
92
+ );
93
+ throw new PlatformSettingsVersionConflictError(
94
+ expectedVersion,
95
+ actual.rows[0] ? Number(actual.rows[0].version) : 0,
96
+ );
97
+ }
98
+ }
@@ -0,0 +1,134 @@
1
+ import {
2
+ ExternalIdentityConflictError,
3
+ ExternalIdentityPersistError,
4
+ type UserExternalIdentity,
5
+ type UserExternalIdentityStore,
6
+ } from "@axiom-lattice/protocols";
7
+ import type { Pool } from "pg";
8
+
9
+ /**
10
+ * Raised when a row read from `lattice_user_identities` does not match the
11
+ * expected column types.
12
+ */
13
+ export class InvalidUserExternalIdentityRowError extends Error {
14
+ readonly code = "INVALID_USER_EXTERNAL_IDENTITY_ROW";
15
+
16
+ constructor() {
17
+ super("Invalid user external identity row");
18
+ this.name = "InvalidUserExternalIdentityRowError";
19
+ }
20
+ }
21
+
22
+ interface Row {
23
+ user_id: unknown;
24
+ provider_id: unknown;
25
+ issuer: unknown;
26
+ subject: unknown;
27
+ email: unknown;
28
+ linked_at: unknown;
29
+ }
30
+
31
+ const columns = "user_id, provider_id, issuer, subject, email, linked_at";
32
+
33
+ function mapRow(row: Row): UserExternalIdentity {
34
+ if (
35
+ typeof row.user_id !== "string" ||
36
+ typeof row.provider_id !== "string" ||
37
+ typeof row.issuer !== "string" ||
38
+ typeof row.subject !== "string" ||
39
+ !(row.linked_at instanceof Date) ||
40
+ Number.isNaN(row.linked_at.getTime())
41
+ ) {
42
+ throw new InvalidUserExternalIdentityRowError();
43
+ }
44
+ return {
45
+ userId: row.user_id,
46
+ providerId: row.provider_id,
47
+ issuer: row.issuer,
48
+ subject: row.subject,
49
+ ...(typeof row.email === "string" ? { email: row.email } : {}),
50
+ linkedAt: row.linked_at,
51
+ };
52
+ }
53
+
54
+ export class PostgreSQLUserExternalIdentityStore implements UserExternalIdentityStore {
55
+ private readonly pool: Pool;
56
+
57
+ constructor(options: { pool: Pool }) {
58
+ this.pool = options.pool;
59
+ }
60
+
61
+ async getByProviderSubject(
62
+ providerId: string,
63
+ issuer: string,
64
+ subject: string,
65
+ ): Promise<UserExternalIdentity | null> {
66
+ const result = await this.pool.query<Row>(
67
+ `SELECT ${columns} FROM lattice_user_identities
68
+ WHERE provider_id = $1 AND issuer = $2 AND subject = $3`,
69
+ [providerId, issuer, subject],
70
+ );
71
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
72
+ }
73
+
74
+ async listByUser(userId: string): Promise<UserExternalIdentity[]> {
75
+ const result = await this.pool.query<Row>(
76
+ `SELECT ${columns} FROM lattice_user_identities WHERE user_id = $1 ORDER BY linked_at ASC`,
77
+ [userId],
78
+ );
79
+ return result.rows.map(mapRow);
80
+ }
81
+
82
+ async link(identity: UserExternalIdentity): Promise<void> {
83
+ const inserted = await this.pool.query<Row>(
84
+ `INSERT INTO lattice_user_identities (${columns})
85
+ VALUES ($1, $2, $3, $4, $5, $6)
86
+ ON CONFLICT (provider_id, issuer, subject) DO NOTHING
87
+ RETURNING ${columns}`,
88
+ [
89
+ identity.userId,
90
+ identity.providerId,
91
+ identity.issuer,
92
+ identity.subject,
93
+ identity.email ?? null,
94
+ identity.linkedAt,
95
+ ],
96
+ );
97
+ if (inserted.rows[0]) return;
98
+
99
+ const existing = await this.getByProviderSubject(
100
+ identity.providerId,
101
+ identity.issuer,
102
+ identity.subject,
103
+ );
104
+ if (!existing) {
105
+ throw new ExternalIdentityPersistError(
106
+ identity.providerId,
107
+ identity.issuer,
108
+ identity.subject,
109
+ );
110
+ }
111
+ if (existing.userId !== identity.userId) {
112
+ throw new ExternalIdentityConflictError(
113
+ identity.providerId,
114
+ identity.issuer,
115
+ identity.subject,
116
+ );
117
+ }
118
+ if (identity.email !== undefined && identity.email !== existing.email) {
119
+ await this.pool.query(
120
+ `UPDATE lattice_user_identities SET email = $1
121
+ WHERE provider_id = $2 AND issuer = $3 AND subject = $4`,
122
+ [identity.email, identity.providerId, identity.issuer, identity.subject],
123
+ );
124
+ }
125
+ }
126
+
127
+ async unlink(providerId: string, issuer: string, subject: string): Promise<void> {
128
+ await this.pool.query(
129
+ `DELETE FROM lattice_user_identities
130
+ WHERE provider_id = $1 AND issuer = $2 AND subject = $3`,
131
+ [providerId, issuer, subject],
132
+ );
133
+ }
134
+ }
@@ -119,9 +119,10 @@ export class PostgreSQLUserStore implements UserStore {
119
119
  `
120
120
  SELECT id, email, name, status, metadata, created_at, updated_at
121
121
  FROM lattice_users
122
- WHERE email = $1
122
+ WHERE lower(email) = lower($1)
123
+ LIMIT 1
123
124
  `,
124
- [email]
125
+ [email.trim()]
125
126
  );
126
127
 
127
128
  if (result.rows.length === 0) return null;