@axiom-lattice/pg-stores 5.0.3 → 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 +95 -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "5.0.3",
3
+ "version": "5.1.0",
4
4
  "description": "PG stores implementation for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@langchain/core": "1.3.2",
26
26
  "pg": "^8.16.3",
27
27
  "uuid": "^9.0.1",
28
- "@axiom-lattice/core": "12.1.1",
29
- "@axiom-lattice/protocols": "7.1.0"
28
+ "@axiom-lattice/core": "12.2.0",
29
+ "@axiom-lattice/protocols": "7.2.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -0,0 +1,63 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+
4
+ jest.mock("@axiom-lattice/core", () => ({
5
+ ...jest.requireActual("../../../core/src/util/encryption"),
6
+ }));
7
+
8
+ import { createInviteLinksTable } from "../migrations/invite_link_migration";
9
+ import { PostgreSQLInviteLinkStore } from "../stores/PostgreSQLInviteLinkStore";
10
+
11
+ process.env.LATTICE_ENCRYPTION_KEY ??= "test-invite-link-encryption-key";
12
+
13
+ const databaseUrl = process.env.TEST_DATABASE_URL;
14
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
15
+
16
+ describeIfDatabase("PostgreSQLInviteLinkStore", () => {
17
+ let pool: Pool;
18
+ let store: PostgreSQLInviteLinkStore;
19
+ const marker = randomUUID();
20
+ beforeAll(async () => {
21
+ pool = new Pool({ connectionString: databaseUrl });
22
+ const client = await pool.connect();
23
+ try {
24
+ await createInviteLinksTable.up(client);
25
+ } finally {
26
+ client.release();
27
+ }
28
+ store = new PostgreSQLInviteLinkStore({ pool });
29
+ });
30
+ afterEach(async () => {
31
+ await pool.query("DELETE FROM lattice_invite_links WHERE email LIKE $1", [`${marker}%`]);
32
+ });
33
+ afterAll(async () => {
34
+ await pool.query("DELETE FROM lattice_invite_links WHERE email LIKE $1", [`${marker}%`]);
35
+ await pool.end();
36
+ });
37
+ it("persists, consumes once, and revokes", async () => {
38
+ const created = await store.create({
39
+ id: randomUUID(),
40
+ scope: "tenant",
41
+ token: "secret-token",
42
+ tokenHash: `${marker}-hash`,
43
+ email: `${marker}@example.com`,
44
+ tenantId: "tenant-1",
45
+ tenantRole: "member",
46
+ expiresAt: new Date("2030-01-01T00:00:00.000Z"),
47
+ maxUses: 1,
48
+ });
49
+ await expect(store.getByTokenHash(created.tokenHash)).resolves.toMatchObject({
50
+ token: "secret-token",
51
+ usedCount: 0,
52
+ });
53
+ await expect(
54
+ store.consume(created.tokenHash, new Date("2026-01-01T00:00:00.000Z")),
55
+ ).resolves.toMatchObject({ usedCount: 1 });
56
+ await expect(
57
+ store.consume(created.tokenHash, new Date("2026-01-01T00:00:00.000Z")),
58
+ ).resolves.toBeNull();
59
+ await expect(
60
+ store.revoke(created.id, new Date("2026-01-01T00:00:00.000Z")),
61
+ ).resolves.toMatchObject({ id: created.id });
62
+ });
63
+ });
@@ -0,0 +1,123 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import {
4
+ OidcIdentityClaimConflictError,
5
+ type CreateOidcIdentityClaimInput,
6
+ } from "@axiom-lattice/protocols";
7
+ import { createOidcIdentityClaimsTable } from "../migrations/oidc_identity_claim_migration";
8
+ import { addOidcIdentityClaimInviteFields } from "../migrations/oidc_identity_claim_invite_migration";
9
+ import { PostgreSQLOidcIdentityClaimStore } from "../stores/PostgreSQLOidcIdentityClaimStore";
10
+
11
+ const databaseUrl = process.env.TEST_DATABASE_URL;
12
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
13
+
14
+ const NOW = new Date("2026-01-01T00:00:00.000Z");
15
+
16
+ function input(
17
+ overrides: Partial<CreateOidcIdentityClaimInput> = {},
18
+ ): CreateOidcIdentityClaimInput {
19
+ return {
20
+ id: randomUUID(),
21
+ providerId: "logto",
22
+ issuer: "https://idp.example.com/oidc",
23
+ subject: `subject-${randomUUID()}`,
24
+ email: "User@Example.com",
25
+ name: "User",
26
+ expiresAt: new Date("2030-01-08T00:00:00.000Z"),
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describeIfDatabase("PostgreSQLOidcIdentityClaimStore", () => {
32
+ let pool: Pool;
33
+ let store: PostgreSQLOidcIdentityClaimStore;
34
+ const marker = randomUUID();
35
+
36
+ beforeAll(async () => {
37
+ pool = new Pool({ connectionString: databaseUrl });
38
+ const client = await pool.connect();
39
+ try {
40
+ await createOidcIdentityClaimsTable.up(client);
41
+ await addOidcIdentityClaimInviteFields.up(client);
42
+ } finally {
43
+ client.release();
44
+ }
45
+ store = new PostgreSQLOidcIdentityClaimStore({ pool });
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await pool.query(
50
+ `DELETE FROM lattice_oidc_identity_claims WHERE subject LIKE $1`,
51
+ [`${marker}%`],
52
+ );
53
+ });
54
+
55
+ afterAll(async () => {
56
+ await pool.query(
57
+ `DELETE FROM lattice_oidc_identity_claims WHERE subject LIKE $1`,
58
+ [`${marker}%`],
59
+ );
60
+ await pool.end();
61
+ });
62
+
63
+ const subject = (local: string) => `${marker}-${local}`;
64
+
65
+ it("creates, finds, updates, and approves a claim", async () => {
66
+ const created = await store.create(
67
+ input({ subject: subject("one") }),
68
+ );
69
+ expect(created).toMatchObject({
70
+ email: "user@example.com",
71
+ status: "pending",
72
+ });
73
+ await expect(
74
+ store.getPendingBySubject(
75
+ "logto",
76
+ "https://idp.example.com/oidc",
77
+ subject("one"),
78
+ NOW,
79
+ ),
80
+ ).resolves.toMatchObject({ id: created.id });
81
+
82
+ await expect(
83
+ store.updatePending(created.id, { email: "new@example.com" }, NOW),
84
+ ).resolves.toMatchObject({ email: "new@example.com" });
85
+ await expect(
86
+ store.approve(
87
+ created.id,
88
+ { reviewedBy: "admin-1", linkedUserId: "user-1" },
89
+ NOW,
90
+ ),
91
+ ).resolves.toMatchObject({
92
+ status: "approved",
93
+ linkedUserId: "user-1",
94
+ });
95
+ await expect(
96
+ store.updatePending(created.id, { email: "late@example.com" }, NOW),
97
+ ).resolves.toBeNull();
98
+ });
99
+
100
+ it("rejects a duplicate live pending subject and allows an expired retry", async () => {
101
+ const subjectId = subject("duplicate");
102
+ await store.create(input({ subject: subjectId }));
103
+ await expect(
104
+ store.create(input({ subject: subjectId })),
105
+ ).rejects.toBeInstanceOf(OidcIdentityClaimConflictError);
106
+
107
+ const expiredSubject = subject("expired");
108
+ await store.create(
109
+ input({
110
+ subject: expiredSubject,
111
+ expiresAt: new Date("2020-01-01T00:00:00.000Z"),
112
+ }),
113
+ );
114
+ await expect(
115
+ store.create(input({ subject: expiredSubject })),
116
+ ).resolves.toMatchObject({ status: "pending" });
117
+ expect(await store.list("expired")).toEqual(
118
+ expect.arrayContaining([
119
+ expect.objectContaining({ subject: expiredSubject, status: "expired" }),
120
+ ]),
121
+ );
122
+ });
123
+ });
@@ -0,0 +1,233 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+
4
+ // The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
5
+ // transitive deps), so mock it with the real encryption module — node:crypto only.
6
+ jest.mock("@axiom-lattice/core", () => ({
7
+ ...jest.requireActual("../../../core/src/util/encryption"),
8
+ }));
9
+
10
+ import { decrypt } from "@axiom-lattice/core";
11
+ import {
12
+ OidcProviderConfigConflictError,
13
+ type CreateOidcProviderConfigInput,
14
+ } from "@axiom-lattice/protocols";
15
+ import { createOidcProviderConfigsTable } from "../migrations/oidc_provider_config_migration";
16
+ import { addOidcProviderAllowedHosts } from "../migrations/oidc_provider_allowed_hosts_migration";
17
+ import { PostgreSQLOidcProviderConfigStore } from "../stores/PostgreSQLOidcProviderConfigStore";
18
+
19
+ process.env.LATTICE_ENCRYPTION_KEY ??= "test-oidc-encryption-key";
20
+
21
+ const databaseUrl = process.env.TEST_DATABASE_URL;
22
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
23
+
24
+ const input: CreateOidcProviderConfigInput = {
25
+ displayName: "Contoso Entra",
26
+ issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
27
+ clientId: "client-id",
28
+ clientSecret: "super-secret",
29
+ scopes: "openid profile email",
30
+ enabled: true,
31
+ jitProvisioning: true,
32
+ autoLinkEmail: false,
33
+ defaultTenantId: "tenant-1",
34
+ };
35
+
36
+ describeIfDatabase("PostgreSQLOidcProviderConfigStore", () => {
37
+ let pool: Pool;
38
+ let store: PostgreSQLOidcProviderConfigStore;
39
+ const prefix = `oidc-${randomUUID()}`;
40
+
41
+ beforeAll(async () => {
42
+ pool = new Pool({ connectionString: databaseUrl });
43
+ const client = await pool.connect();
44
+ try {
45
+ await createOidcProviderConfigsTable.up(client);
46
+ await addOidcProviderAllowedHosts.up(client);
47
+ } finally {
48
+ client.release();
49
+ }
50
+ store = new PostgreSQLOidcProviderConfigStore({ pool });
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await pool.query(
55
+ `DELETE FROM lattice_oidc_provider_configs WHERE id LIKE $1`,
56
+ [`${prefix}%`],
57
+ );
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await pool.query(
62
+ `DELETE FROM lattice_oidc_provider_configs WHERE id LIKE $1`,
63
+ [`${prefix}%`],
64
+ );
65
+ await pool.end();
66
+ });
67
+
68
+ it("creates, reads and lists provider configs by created_at", async () => {
69
+ const created = await store.create(`${prefix}-a`, input);
70
+
71
+ expect(created).toMatchObject({ id: `${prefix}-a`, ...input });
72
+ expect(created.createdAt).toBeInstanceOf(Date);
73
+ expect(created.updatedAt).toBeInstanceOf(Date);
74
+
75
+ await store.create(`${prefix}-m`, { ...input, displayName: "Mike" });
76
+ await store.create(`${prefix}-z`, { ...input, displayName: "Zulu" });
77
+
78
+ // Make created_at order intentionally differ from both insertion and
79
+ // alphabetical id order.
80
+ const timestamps: Record<string, string> = {
81
+ [`${prefix}-z`]: "2020-01-01T00:00:01.000Z",
82
+ [`${prefix}-a`]: "2020-01-01T00:00:02.000Z",
83
+ [`${prefix}-m`]: "2020-01-01T00:00:03.000Z",
84
+ };
85
+ for (const [id, createdAt] of Object.entries(timestamps)) {
86
+ await pool.query(
87
+ `UPDATE lattice_oidc_provider_configs SET created_at = $1 WHERE id = $2`,
88
+ [createdAt, id],
89
+ );
90
+ }
91
+
92
+ const listed = (await store.list()).filter((item) => item.id.startsWith(prefix));
93
+ expect(listed.map((item) => item.id)).toEqual([
94
+ `${prefix}-z`,
95
+ `${prefix}-a`,
96
+ `${prefix}-m`,
97
+ ]);
98
+ await expect(store.get("missing")).resolves.toBeNull();
99
+ });
100
+
101
+ it("encrypts the client secret at rest and decrypts it on read", async () => {
102
+ const id = `${prefix}-enc`;
103
+ await store.create(id, input);
104
+
105
+ const raw = await pool.query<{ client_secret: string }>(
106
+ `SELECT client_secret FROM lattice_oidc_provider_configs WHERE id = $1`,
107
+ [id],
108
+ );
109
+ expect(raw.rows[0].client_secret).not.toBe("super-secret");
110
+ expect(decrypt(raw.rows[0].client_secret)).toBe("super-secret");
111
+
112
+ await expect(store.get(id)).resolves.toMatchObject({
113
+ clientSecret: "super-secret",
114
+ });
115
+ const listed = (await store.list()).find((item) => item.id === id);
116
+ expect(listed?.clientSecret).toBe("super-secret");
117
+ });
118
+
119
+ it("returns an empty secret for undecryptable ciphertext without crashing", async () => {
120
+ const id = `${prefix}-corrupt`;
121
+ await store.create(id, input);
122
+
123
+ const corrupt = Buffer.alloc(80, 7).toString("base64");
124
+ await pool.query(
125
+ `UPDATE lattice_oidc_provider_configs SET client_secret = $1 WHERE id = $2`,
126
+ [corrupt, id],
127
+ );
128
+
129
+ await expect(store.get(id)).resolves.toMatchObject({
130
+ id,
131
+ clientSecret: "",
132
+ });
133
+ const listed = (await store.list()).find((item) => item.id === id);
134
+ expect(listed?.clientSecret).toBe("");
135
+ });
136
+
137
+ it("updates fields, preserves secrets and rejects duplicate ids", async () => {
138
+ const id = `${prefix}-update`;
139
+ await store.create(id, input);
140
+
141
+ await expect(store.create(id, input)).rejects.toBeInstanceOf(
142
+ OidcProviderConfigConflictError,
143
+ );
144
+
145
+ const omitted = await store.update(id, {
146
+ displayName: "Contoso Updated",
147
+ enabled: false,
148
+ defaultTenantId: null,
149
+ });
150
+ expect(omitted).toMatchObject({
151
+ id,
152
+ displayName: "Contoso Updated",
153
+ enabled: false,
154
+ clientSecret: "super-secret",
155
+ });
156
+ expect(omitted?.defaultTenantId).toBeUndefined();
157
+
158
+ const empty = await store.update(id, { clientSecret: "" });
159
+ expect(empty?.clientSecret).toBe("super-secret");
160
+
161
+ const replaced = await store.update(id, {
162
+ clientSecret: "next-secret",
163
+ scopes: "openid email",
164
+ });
165
+ expect(replaced).toMatchObject({
166
+ clientSecret: "next-secret",
167
+ scopes: "openid email",
168
+ });
169
+
170
+ const raw = await pool.query<{ client_secret: string }>(
171
+ `SELECT client_secret FROM lattice_oidc_provider_configs WHERE id = $1`,
172
+ [id],
173
+ );
174
+ expect(raw.rows[0].client_secret).not.toBe("next-secret");
175
+ expect(decrypt(raw.rows[0].client_secret)).toBe("next-secret");
176
+
177
+ await expect(store.update("missing", { enabled: false })).resolves.toBeNull();
178
+ });
179
+
180
+ it("round-trips undefined and empty allowedPrivateHosts distinctly", async () => {
181
+ const undefinedId = `${prefix}-hosts-undefined`;
182
+ const createdUndefined = await store.create(undefinedId, input);
183
+ expect(createdUndefined.allowedPrivateHosts).toBeUndefined();
184
+ const rawUndefined = await pool.query<{
185
+ allowed_private_hosts: string[] | null;
186
+ }>(
187
+ `SELECT allowed_private_hosts FROM lattice_oidc_provider_configs WHERE id = $1`,
188
+ [undefinedId],
189
+ );
190
+ expect(rawUndefined.rows[0].allowed_private_hosts).toBeNull();
191
+
192
+ const emptyId = `${prefix}-hosts-empty`;
193
+ const createdEmpty = await store.create(emptyId, {
194
+ ...input,
195
+ allowedPrivateHosts: [],
196
+ });
197
+ expect(createdEmpty.allowedPrivateHosts).toEqual([]);
198
+ const rawEmpty = await pool.query<{
199
+ allowed_private_hosts: string[] | null;
200
+ }>(
201
+ `SELECT allowed_private_hosts FROM lattice_oidc_provider_configs WHERE id = $1`,
202
+ [emptyId],
203
+ );
204
+ expect(rawEmpty.rows[0].allowed_private_hosts).toEqual([]);
205
+ await expect(store.get(emptyId)).resolves.toMatchObject({
206
+ allowedPrivateHosts: [],
207
+ });
208
+
209
+ const listId = `${prefix}-hosts-list`;
210
+ await store.create(listId, {
211
+ ...input,
212
+ allowedPrivateHosts: ["internal-idp.example.com"],
213
+ });
214
+ await expect(store.get(listId)).resolves.toMatchObject({
215
+ allowedPrivateHosts: ["internal-idp.example.com"],
216
+ });
217
+
218
+ const cleared = await store.update(listId, { allowedPrivateHosts: [] });
219
+ expect(cleared?.allowedPrivateHosts).toEqual([]);
220
+ await expect(store.get(listId)).resolves.toMatchObject({
221
+ allowedPrivateHosts: [],
222
+ });
223
+ });
224
+
225
+ it("deletes provider configs", async () => {
226
+ const id = `${prefix}-delete`;
227
+ await store.create(id, input);
228
+
229
+ await expect(store.delete(id)).resolves.toBe(true);
230
+ await expect(store.get(id)).resolves.toBeNull();
231
+ await expect(store.delete(id)).resolves.toBe(false);
232
+ });
233
+ });
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import type { PlatformInvitation } from "@axiom-lattice/protocols";
4
+ import { createPlatformInvitationsTable } from "../migrations/platform_invitation_migration";
5
+ import { PostgreSQLPlatformInvitationStore } from "../stores/PostgreSQLPlatformInvitationStore";
6
+
7
+ const databaseUrl = process.env.TEST_DATABASE_URL;
8
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
9
+
10
+ function invitation(
11
+ email: string,
12
+ overrides: Partial<PlatformInvitation> = {},
13
+ ): PlatformInvitation {
14
+ return {
15
+ email,
16
+ role: "admin",
17
+ invitedBy: "owner-1",
18
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describeIfDatabase("PostgreSQLPlatformInvitationStore", () => {
24
+ let pool: Pool;
25
+ let store: PostgreSQLPlatformInvitationStore;
26
+ const marker = randomUUID();
27
+
28
+ beforeAll(async () => {
29
+ pool = new Pool({ connectionString: databaseUrl });
30
+ const client = await pool.connect();
31
+ try {
32
+ await createPlatformInvitationsTable.up(client);
33
+ } finally {
34
+ client.release();
35
+ }
36
+ store = new PostgreSQLPlatformInvitationStore({ pool });
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await pool.query(
41
+ `DELETE FROM lattice_platform_invitations WHERE email LIKE $1`,
42
+ [`${marker}%`],
43
+ );
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await pool.query(
48
+ `DELETE FROM lattice_platform_invitations WHERE email LIKE $1`,
49
+ [`${marker}%`],
50
+ );
51
+ await pool.end();
52
+ });
53
+
54
+ const email = (local: string) => `${marker}-${local}@example.com`;
55
+
56
+ it("upserts, reads and lists invitations by normalized email", async () => {
57
+ const bob = email("bob");
58
+ const alice = email("alice");
59
+
60
+ await store.upsert(invitation(bob, { role: "auditor" }));
61
+ await store.upsert(invitation(alice, { role: "owner" }));
62
+
63
+ await expect(store.get(alice)).resolves.toMatchObject({
64
+ email: alice,
65
+ role: "owner",
66
+ invitedBy: "owner-1",
67
+ });
68
+ await expect(store.get(email("missing"))).resolves.toBeNull();
69
+
70
+ const listed = (await store.list()).filter((entry) =>
71
+ entry.email.includes(marker),
72
+ );
73
+ expect(listed.map((entry) => entry.email)).toEqual([alice, bob]);
74
+ });
75
+
76
+ it("normalizes email on writes and lookups", async () => {
77
+ const local = email("normalized");
78
+ const upper = `${marker}-NORMALIZED@Example.COM`;
79
+
80
+ await store.upsert(invitation(` ${upper} `));
81
+
82
+ await expect(store.get(local)).resolves.toMatchObject({
83
+ email: local,
84
+ });
85
+ await expect(store.get(` ${local.toUpperCase()} `)).resolves.toMatchObject({
86
+ email: local,
87
+ });
88
+ const listed = (await store.list()).filter((entry) =>
89
+ entry.email.includes(marker),
90
+ );
91
+ expect(listed).toHaveLength(1);
92
+ expect(listed[0]?.email).toBe(local);
93
+ });
94
+
95
+ it("replaces duplicates with last-write-wins semantics", async () => {
96
+ const target = email("duplicate");
97
+ await store.upsert(invitation(target, { role: "admin" }));
98
+
99
+ const updated = await store.upsert(
100
+ invitation(` ${target.toUpperCase()} `, {
101
+ role: "owner",
102
+ invitedBy: "owner-2",
103
+ createdAt: new Date("2026-02-01T00:00:00.000Z"),
104
+ expiresAt: new Date("2026-03-01T00:00:00.000Z"),
105
+ }),
106
+ );
107
+
108
+ expect(updated).toMatchObject({
109
+ email: target,
110
+ role: "owner",
111
+ invitedBy: "owner-2",
112
+ });
113
+ const listed = (await store.list()).filter((entry) =>
114
+ entry.email.includes(marker),
115
+ );
116
+ expect(listed).toHaveLength(1);
117
+ await expect(store.get(target)).resolves.toMatchObject({
118
+ role: "owner",
119
+ invitedBy: "owner-2",
120
+ });
121
+
122
+ const row = await pool.query<{
123
+ email: string;
124
+ role: string;
125
+ invited_by: string;
126
+ }>(
127
+ `SELECT email, role, invited_by FROM lattice_platform_invitations WHERE email = $1`,
128
+ [target],
129
+ );
130
+ expect(row.rows[0]).toEqual({
131
+ email: target,
132
+ role: "owner",
133
+ invited_by: "owner-2",
134
+ });
135
+ });
136
+
137
+ it("consumes pending unexpired invitations atomically", async () => {
138
+ const target = email("consume");
139
+ const at = new Date("2026-02-01T00:00:00.000Z");
140
+ await store.upsert(
141
+ invitation(target, {
142
+ expiresAt: new Date("2026-03-01T00:00:00.000Z"),
143
+ }),
144
+ );
145
+
146
+ const consumed = await store.consume(` ${target.toUpperCase()} `, at);
147
+
148
+ expect(consumed).toMatchObject({
149
+ email: target,
150
+ consumedAt: at,
151
+ });
152
+ await expect(store.get(target)).resolves.toMatchObject({
153
+ consumedAt: at,
154
+ });
155
+ });
156
+
157
+ it("does not consume an already-consumed invitation", async () => {
158
+ const target = email("consumed-noop");
159
+ const first = new Date("2026-02-01T00:00:00.000Z");
160
+ const second = new Date("2026-02-02T00:00:00.000Z");
161
+ await store.upsert(invitation(target));
162
+
163
+ await expect(store.consume(target, first)).resolves.toMatchObject({
164
+ consumedAt: first,
165
+ });
166
+ await expect(store.consume(target, second)).resolves.toBeNull();
167
+ await expect(store.get(target)).resolves.toMatchObject({
168
+ consumedAt: first,
169
+ });
170
+ });
171
+
172
+ it("does not consume an expired invitation", async () => {
173
+ const target = email("expired");
174
+ const expiresAt = new Date("2026-02-01T00:00:00.000Z");
175
+ await store.upsert(invitation(target, { expiresAt }));
176
+
177
+ await expect(store.consume(target, expiresAt)).resolves.toBeNull();
178
+ await expect(
179
+ store.consume(target, new Date("2026-01-31T23:59:59.000Z")),
180
+ ).resolves.toMatchObject({
181
+ consumedAt: new Date("2026-01-31T23:59:59.000Z"),
182
+ });
183
+ });
184
+
185
+ it("returns null when consuming a missing invitation", async () => {
186
+ await expect(store.consume(email("missing"), new Date())).resolves.toBeNull();
187
+ });
188
+
189
+ it("deletes invitations", async () => {
190
+ const target = email("delete");
191
+ await store.upsert(invitation(target));
192
+
193
+ await expect(store.delete(` ${target.toUpperCase()} `)).resolves.toBe(true);
194
+ await expect(store.get(target)).resolves.toBeNull();
195
+ await expect(store.delete(target)).resolves.toBe(false);
196
+ });
197
+
198
+ it("omits optional fields when they are not provided", async () => {
199
+ const target = email("optional");
200
+ const { invitedBy: _invitedBy, ...withoutInvitedBy } = invitation(target);
201
+
202
+ await store.upsert(withoutInvitedBy);
203
+
204
+ const stored = await store.get(target);
205
+ expect(stored?.invitedBy).toBeUndefined();
206
+ expect(stored?.expiresAt).toBeUndefined();
207
+ expect(stored?.consumedAt).toBeUndefined();
208
+ });
209
+
210
+ it("round-trips timestamps", async () => {
211
+ const target = email("timestamps");
212
+ const createdAt = new Date("2026-03-04T05:06:07.000Z");
213
+ const expiresAt = new Date("2026-04-05T06:07:08.000Z");
214
+
215
+ await store.upsert(invitation(target, { createdAt, expiresAt }));
216
+
217
+ const stored = await store.get(target);
218
+ expect(stored?.createdAt).toBeInstanceOf(Date);
219
+ expect(stored?.createdAt.getTime()).toBe(createdAt.getTime());
220
+ expect(stored?.expiresAt?.getTime()).toBe(expiresAt.getTime());
221
+ });
222
+ });