@yielded/auth-persistence 0.1.0-beta.23 → 0.1.0-beta.25

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 (182) hide show
  1. package/README.md +11 -0
  2. package/dist/Adapter.d.mts +12 -6
  3. package/dist/Adapter.mjs +8 -3
  4. package/dist/OAuthPersistence.d.mts +87 -0
  5. package/dist/OAuthPersistence.mjs +125 -0
  6. package/dist/OAuthPersistence.mjs.map +1 -0
  7. package/dist/_virtual/_rolldown/runtime.mjs +13 -0
  8. package/dist/index.d.mts +3 -1
  9. package/dist/index.mjs +3 -1
  10. package/dist/internal/configuration.d.mts +1 -1
  11. package/dist/internal/configuration.mjs.map +1 -1
  12. package/dist/internal/email-kernel.d.mts +1 -1
  13. package/dist/internal/email-kernel.mjs +1 -1
  14. package/dist/internal/email-kernel.mjs.map +1 -1
  15. package/dist/internal/models/oauth-connected-model.d.mts +294 -0
  16. package/dist/internal/models/oauth-connected-model.mjs +22 -0
  17. package/dist/internal/models/oauth-connected-model.mjs.map +1 -0
  18. package/dist/internal/models/oauth-model.d.mts +467 -0
  19. package/dist/internal/models/oauth-model.mjs +49 -0
  20. package/dist/internal/models/oauth-model.mjs.map +1 -0
  21. package/dist/internal/models/password-model.d.mts +6 -48
  22. package/dist/internal/models/password-model.mjs +0 -1
  23. package/dist/internal/models/password-model.mjs.map +1 -1
  24. package/dist/internal/native-sql-table.d.mts +4 -0
  25. package/dist/internal/native-sql-table.mjs +7 -0
  26. package/dist/internal/native-sql-table.mjs.map +1 -1
  27. package/dist/internal/oauth/accounts.d.mts +8 -0
  28. package/dist/internal/oauth/accounts.mjs +379 -0
  29. package/dist/internal/oauth/accounts.mjs.map +1 -0
  30. package/dist/internal/oauth/connected-access.d.mts +9 -0
  31. package/dist/internal/oauth/connected-access.mjs +229 -0
  32. package/dist/internal/oauth/connected-access.mjs.map +1 -0
  33. package/dist/internal/oauth/connected-collection.d.mts +7 -0
  34. package/dist/internal/oauth/connected-collection.mjs +214 -0
  35. package/dist/internal/oauth/connected-collection.mjs.map +1 -0
  36. package/dist/internal/oauth/connected-custody.d.mts +6 -0
  37. package/dist/internal/oauth/connected-custody.mjs +165 -0
  38. package/dist/internal/oauth/connected-custody.mjs.map +1 -0
  39. package/dist/internal/oauth/connected-flow.d.mts +7 -0
  40. package/dist/internal/oauth/connected-flow.mjs +324 -0
  41. package/dist/internal/oauth/connected-flow.mjs.map +1 -0
  42. package/dist/internal/oauth/connected-input.mjs +122 -0
  43. package/dist/internal/oauth/connected-input.mjs.map +1 -0
  44. package/dist/internal/oauth/connected-keys.d.mts +2 -0
  45. package/dist/internal/oauth/connected-keys.mjs +22 -0
  46. package/dist/internal/oauth/connected-keys.mjs.map +1 -0
  47. package/dist/internal/oauth/connected-maintenance.d.mts +9 -0
  48. package/dist/internal/oauth/connected-maintenance.mjs +226 -0
  49. package/dist/internal/oauth/connected-maintenance.mjs.map +1 -0
  50. package/dist/internal/oauth/connected-management.d.mts +9 -0
  51. package/dist/internal/oauth/connected-management.mjs +243 -0
  52. package/dist/internal/oauth/connected-management.mjs.map +1 -0
  53. package/dist/internal/oauth/connected-reference.mjs +63 -0
  54. package/dist/internal/oauth/connected-reference.mjs.map +1 -0
  55. package/dist/internal/oauth/connected-settlement.d.mts +8 -0
  56. package/dist/internal/oauth/connected-settlement.mjs +141 -0
  57. package/dist/internal/oauth/connected-settlement.mjs.map +1 -0
  58. package/dist/internal/oauth/connected-sign-in.d.mts +10 -0
  59. package/dist/internal/oauth/connected-sign-in.mjs +281 -0
  60. package/dist/internal/oauth/connected-sign-in.mjs.map +1 -0
  61. package/dist/internal/oauth/connected-state.d.mts +14 -0
  62. package/dist/internal/oauth/connected-state.mjs +469 -0
  63. package/dist/internal/oauth/connected-state.mjs.map +1 -0
  64. package/dist/internal/oauth/connected-target.d.mts +21 -0
  65. package/dist/internal/oauth/connected-target.mjs +117 -0
  66. package/dist/internal/oauth/connected-target.mjs.map +1 -0
  67. package/dist/internal/oauth/flow.d.mts +5 -0
  68. package/dist/internal/oauth/flow.mjs +279 -0
  69. package/dist/internal/oauth/flow.mjs.map +1 -0
  70. package/dist/internal/oauth/input.mjs +127 -0
  71. package/dist/internal/oauth/input.mjs.map +1 -0
  72. package/dist/internal/oauth/owner.d.mts +10 -0
  73. package/dist/internal/oauth/owner.mjs +10 -0
  74. package/dist/internal/oauth/owner.mjs.map +1 -0
  75. package/dist/internal/oauth/query-compiler.d.mts +7 -0
  76. package/dist/internal/oauth/query-compiler.mjs +9 -0
  77. package/dist/internal/oauth/query-compiler.mjs.map +1 -0
  78. package/dist/internal/oauth/registration.d.mts +7 -0
  79. package/dist/internal/oauth/registration.mjs +393 -0
  80. package/dist/internal/oauth/registration.mjs.map +1 -0
  81. package/dist/internal/oauth/state.mjs +107 -0
  82. package/dist/internal/oauth/state.mjs.map +1 -0
  83. package/dist/internal/oauth/target.d.mts +31 -0
  84. package/dist/internal/oauth/target.mjs +273 -0
  85. package/dist/internal/oauth/target.mjs.map +1 -0
  86. package/dist/internal/oauth-kernel.d.mts +1423 -0
  87. package/dist/internal/oauth-kernel.mjs +187 -0
  88. package/dist/internal/oauth-kernel.mjs.map +1 -0
  89. package/dist/internal/oauth-proxy.d.mts +31 -0
  90. package/dist/internal/oauth-proxy.mjs +129 -0
  91. package/dist/internal/oauth-proxy.mjs.map +1 -0
  92. package/dist/internal/passkey/write-state.mjs +9 -1
  93. package/dist/internal/passkey/write-state.mjs.map +1 -1
  94. package/dist/internal/passkey-kernel.d.mts +9 -9
  95. package/dist/internal/{password-attempts.d.mts → password-credentials.d.mts} +3 -4
  96. package/dist/internal/password-credentials.mjs +121 -0
  97. package/dist/internal/password-credentials.mjs.map +1 -0
  98. package/dist/internal/password-kernel.d.mts +8 -24
  99. package/dist/internal/password-kernel.mjs +5 -43
  100. package/dist/internal/password-kernel.mjs.map +1 -1
  101. package/dist/internal/persistence.d.mts +1 -1
  102. package/dist/internal/persistence.mjs +2 -2
  103. package/dist/internal/persistence.mjs.map +1 -1
  104. package/dist/internal/query-operations.d.mts +2 -0
  105. package/dist/internal/session-kernel.d.mts +2 -2
  106. package/dist/internal/session-kernel.mjs +74 -18
  107. package/dist/internal/session-kernel.mjs.map +1 -1
  108. package/dist/internal/sql-oauth-connected-model.d.mts +29 -0
  109. package/dist/internal/sql-oauth-connected-model.mjs +7 -0
  110. package/dist/internal/sql-oauth-connected-model.mjs.map +1 -0
  111. package/dist/internal/sql-oauth-model.d.mts +60 -0
  112. package/dist/internal/sql-oauth-model.mjs +10 -0
  113. package/dist/internal/sql-oauth-model.mjs.map +1 -0
  114. package/dist/internal/sql-query.d.mts +12 -0
  115. package/dist/internal/sql-query.mjs +15 -18
  116. package/dist/internal/sql-query.mjs.map +1 -1
  117. package/dist/internal/sql-storage-validation.mjs +54 -0
  118. package/dist/internal/sql-storage-validation.mjs.map +1 -0
  119. package/dist/internal/storage-mapping.mjs +1 -37
  120. package/dist/internal/storage-mapping.mjs.map +1 -1
  121. package/dist/internal/storage-plans.d.mts +7 -0
  122. package/dist/internal/storage-plans.mjs +189 -0
  123. package/dist/internal/storage-plans.mjs.map +1 -0
  124. package/dist/internal/storage-tables.d.mts +0 -56
  125. package/dist/internal/storage-tables.mjs +0 -17
  126. package/dist/internal/storage-tables.mjs.map +1 -1
  127. package/dist/internal/storage-validation.d.mts +6 -1
  128. package/dist/internal/storage-validation.mjs +42 -1
  129. package/dist/internal/storage-validation.mjs.map +1 -1
  130. package/dist/internal/transaction-kernel.mjs +3 -2
  131. package/dist/internal/transaction-kernel.mjs.map +1 -1
  132. package/package.json +1 -1
  133. package/src/Adapter.ts +89 -5
  134. package/src/OAuthPersistence.ts +427 -0
  135. package/src/index.ts +3 -0
  136. package/src/internal/configuration.ts +1 -3
  137. package/src/internal/email-kernel.ts +2 -1
  138. package/src/internal/models/oauth-connected-model.ts +396 -0
  139. package/src/internal/models/oauth-model.ts +708 -0
  140. package/src/internal/models/password-model.ts +2 -57
  141. package/src/internal/native-sql-table.ts +14 -0
  142. package/src/internal/oauth/accounts.ts +839 -0
  143. package/src/internal/oauth/connected-access.ts +388 -0
  144. package/src/internal/oauth/connected-collection.ts +345 -0
  145. package/src/internal/oauth/connected-custody.ts +308 -0
  146. package/src/internal/oauth/connected-flow.ts +562 -0
  147. package/src/internal/oauth/connected-input.ts +147 -0
  148. package/src/internal/oauth/connected-keys.ts +31 -0
  149. package/src/internal/oauth/connected-maintenance.ts +379 -0
  150. package/src/internal/oauth/connected-management.ts +391 -0
  151. package/src/internal/oauth/connected-reference.ts +135 -0
  152. package/src/internal/oauth/connected-settlement.ts +278 -0
  153. package/src/internal/oauth/connected-sign-in.ts +472 -0
  154. package/src/internal/oauth/connected-state.ts +924 -0
  155. package/src/internal/oauth/connected-target.ts +307 -0
  156. package/src/internal/oauth/flow.ts +505 -0
  157. package/src/internal/oauth/input.ts +201 -0
  158. package/src/internal/oauth/owner.ts +22 -0
  159. package/src/internal/oauth/query-compiler.ts +15 -0
  160. package/src/internal/oauth/registration.ts +676 -0
  161. package/src/internal/oauth/state.ts +218 -0
  162. package/src/internal/oauth/target.ts +803 -0
  163. package/src/internal/oauth-kernel.ts +294 -0
  164. package/src/internal/oauth-proxy.ts +176 -0
  165. package/src/internal/passkey/write-state.ts +18 -4
  166. package/src/internal/password-credentials.ts +245 -0
  167. package/src/internal/password-kernel.ts +7 -99
  168. package/src/internal/persistence.ts +1 -1
  169. package/src/internal/query-operations.ts +2 -0
  170. package/src/internal/session-kernel.ts +143 -55
  171. package/src/internal/sql-oauth-connected-model.ts +145 -0
  172. package/src/internal/sql-oauth-model.ts +263 -0
  173. package/src/internal/sql-query.ts +16 -13
  174. package/src/internal/sql-storage-validation.ts +64 -0
  175. package/src/internal/storage-mapping.ts +0 -37
  176. package/src/internal/storage-plans.ts +218 -0
  177. package/src/internal/storage-tables.ts +0 -20
  178. package/src/internal/storage-validation.ts +58 -0
  179. package/src/internal/transaction-kernel.ts +6 -2
  180. package/dist/internal/password-attempts.mjs +0 -197
  181. package/dist/internal/password-attempts.mjs.map +0 -1
  182. package/src/internal/password-attempts.ts +0 -466
@@ -0,0 +1,345 @@
1
+ import { Effect, Context, Layer } from "effect";
2
+
3
+ import { OAuthConnectedCustody } from "./connected-custody";
4
+ import { OAuthConnectedFlow } from "./connected-flow";
5
+ import { OAuthConnectedState } from "./connected-state";
6
+ import type * as STypes from "./connected-state";
7
+ import { CurrentOAuthTransaction } from "./owner";
8
+ import type { Row } from "./owner";
9
+ import { OAuthQueryCompiler } from "./query-compiler";
10
+ import { invariant, oauthIdentityKey } from "./state";
11
+
12
+ export interface Candidates {
13
+ readonly jobs: ReadonlyArray<Row>;
14
+ readonly grants: ReadonlyArray<Row>;
15
+ readonly cohorts: ReadonlyArray<Row>;
16
+ readonly tuples: ReadonlyArray<Row>;
17
+ }
18
+
19
+ const make = Effect.gen(function* () {
20
+ const operations = yield* OAuthQueryCompiler;
21
+ const C = yield* OAuthConnectedCustody;
22
+ const F = yield* OAuthConnectedFlow;
23
+ const S = yield* OAuthConnectedState;
24
+
25
+ const { asc, eq, lte, sql } = operations;
26
+ const { connectedReferenceCondition } = operations;
27
+ const { both, col, equal } = operations;
28
+
29
+ const discover = Effect.fn("oauthConnected.collectionCandidates")(function* (
30
+ mapping: STypes.Mapping,
31
+ moduleId: string,
32
+ limit: number,
33
+ ) {
34
+ const owner = yield* CurrentOAuthTransaction;
35
+
36
+ const g = mapping.grant,
37
+ j = S.jobTable(mapping),
38
+ h = mapping.cohort,
39
+ at = yield* owner.now(mapping.clock);
40
+
41
+ const jobs =
42
+ j === undefined
43
+ ? { rows: [] }
44
+ : yield* owner.read(
45
+ j.table,
46
+ both(
47
+ eq(col(j.table, j.moduleId), moduleId),
48
+ eq(col(j.table, j.state), "Confirmed"),
49
+ lte(col(j.table, j.retentionUntil), mapping.clock.encodeInstant(at)),
50
+ ),
51
+ {
52
+ limit: limit + 1,
53
+ takeOnly: true,
54
+ observe: false,
55
+ lock: false,
56
+ orderBy: asc(col(j.table, j.jobId)),
57
+ },
58
+ );
59
+
60
+ const grants = yield* owner.read(
61
+ g.table,
62
+ both(
63
+ eq(col(g.table, g.moduleId), moduleId),
64
+ sql`${col(g.table, g.state)} in ('Disconnected','ReauthorizationRequired')`,
65
+ sql`${col(g.table, g.sealed)} is null`,
66
+ sql`${col(g.table, g.refreshWork)} <> 'Unresolved'`,
67
+ lte(col(g.table, g.retentionUntil), mapping.clock.encodeInstant(at)),
68
+ sql`${col(g.table, g.revocationJobId)} is null`,
69
+ sql`not exists(select 1 from ${mapping.admission.table} where ${col(mapping.admission.table, mapping.admission.moduleId)} = ${col(g.table, g.moduleId)} and ${col(mapping.admission.table, mapping.admission.grantId)} = ${col(g.table, g.grantId)})`,
70
+ sql`not exists(select 1 from ${mapping.command.table} where ${col(mapping.command.table, mapping.command.moduleId)} = ${col(g.table, g.moduleId)} and ${col(mapping.command.table, mapping.command.grantId)} = ${col(g.table, g.grantId)})`,
71
+ j === undefined
72
+ ? undefined
73
+ : sql`not exists(select 1 from ${j.table} where ${col(j.table, j.moduleId)} = ${col(g.table, g.moduleId)} and ${col(j.table, j.grantId)} = ${col(g.table, g.grantId)})`,
74
+ ),
75
+ {
76
+ limit: limit + 1,
77
+ takeOnly: true,
78
+ observe: false,
79
+ lock: false,
80
+ orderBy: asc(col(g.table, g.grantId)),
81
+ },
82
+ );
83
+
84
+ const cohorts =
85
+ j === undefined
86
+ ? { rows: [] }
87
+ : yield* owner.read(
88
+ h.table,
89
+ both(
90
+ equal(h.table, { [h.state]: "Blocked" }),
91
+ C.clearableCondition(
92
+ mapping,
93
+ sql`${col(h.table, h.cohortKey)}`,
94
+ sql`${col(h.table, h.clientKey)}`,
95
+ ),
96
+ ),
97
+ {
98
+ limit: limit + 1,
99
+ takeOnly: true,
100
+ observe: false,
101
+ lock: false,
102
+ orderBy: asc(col(h.table, h.cohortKey)),
103
+ },
104
+ );
105
+
106
+ const t = mapping.ownership.tuple;
107
+
108
+ const tuples =
109
+ mapping.externalReference === undefined
110
+ ? { rows: [] }
111
+ : yield* owner.read(
112
+ t.table,
113
+ both(
114
+ eq(col(t.table, t.state), "Owned"),
115
+ sql`not (${connectedReferenceCondition(mapping, sql`${col(t.table, t.identityKey)}`, { provider: sql`${col(t.table, t.provider)}`, issuer: sql`${col(t.table, t.issuer)}` })})`,
116
+ sql`not (${mapping.externalReference({ identityKey: sql`${col(t.table, t.identityKey)}`, subjectId: sql`${col(t.table, t.subjectId)}` })})`,
117
+ ),
118
+ {
119
+ limit: limit + 1,
120
+ takeOnly: true,
121
+ observe: false,
122
+ lock: false,
123
+ orderBy: asc(col(t.table, t.identityKey)),
124
+ },
125
+ );
126
+
127
+ return { jobs: jobs.rows, grants: grants.rows, cohorts: cohorts.rows, tuples: tuples.rows };
128
+ });
129
+
130
+ const collect = Effect.fn("oauthConnected.collect")(function* (
131
+ mapping: STypes.Mapping,
132
+ moduleId: string,
133
+ limit: number,
134
+ discovered: Candidates,
135
+ ) {
136
+ const owner = yield* CurrentOAuthTransaction;
137
+
138
+ let visited = 0,
139
+ removed = 0,
140
+ hasMore = false;
141
+
142
+ if (limit === 0) return { visited, removed, hasMore: true };
143
+
144
+ const g = mapping.grant,
145
+ j = S.jobTable(mapping),
146
+ h = mapping.cohort;
147
+
148
+ if (j !== undefined) {
149
+ const candidates = { rows: discovered.jobs };
150
+
151
+ hasMore = candidates.rows.length > limit;
152
+ for (const candidate of candidates.rows.slice(0, limit)) {
153
+ visited++;
154
+
155
+ const job = S.jobStorage.decode(candidate[j.snapshot]),
156
+ token = job.context.token;
157
+
158
+ const client = yield* S.client(mapping, token.configuration, false);
159
+
160
+ invariant(client !== undefined);
161
+ const tuple = yield* F.inspectTuple(mapping, token.identity);
162
+ const cohort = yield* S.cohort(mapping, client.id, tuple.key, false);
163
+
164
+ invariant(cohort.row !== undefined);
165
+ const grant = yield* S.readGrant(mapping, token.moduleId, token.grantId);
166
+
167
+ const read = yield* owner.read(j.table, equal(j.table, { [j.jobId]: job.context.jobId }), {
168
+ limit: 1,
169
+ }),
170
+ row = read.rows[0];
171
+
172
+ if (row === undefined) continue;
173
+ const native = yield* mapping.subjectId.toNative(token.subjectId);
174
+
175
+ invariant(
176
+ row[j.snapshot] === S.jobStorage.encode(job) &&
177
+ row[j.moduleId] === moduleId &&
178
+ row[j.identityKey] === tuple.key &&
179
+ row[j.clientKey] === client.id &&
180
+ row[j.cohortKey] === cohort.id &&
181
+ row[j.grantId] === token.grantId &&
182
+ mapping.subjectId.equals(row[j.subjectId], native),
183
+ );
184
+
185
+ const now = yield* owner.now(mapping.clock),
186
+ until = mapping.clock.decodeInstant(row[j.retentionUntil]);
187
+
188
+ if (row[j.state] !== "Confirmed" || now < until) continue;
189
+ if (grant?.row[g.revocationJobId] === job.context.jobId) {
190
+ const summary = S.summaryStorage.decode(grant.row[g.summary]);
191
+
192
+ yield* owner.update(
193
+ g.table,
194
+ { [g.moduleId]: token.moduleId, [g.grantId]: token.grantId },
195
+ {
196
+ [g.revocationJobId]: null,
197
+ [g.summary]: S.summaryStorage.encode({ ...summary, remoteRevocation: "Confirmed" }),
198
+ [g.version]: owner.marker,
199
+ },
200
+ );
201
+ }
202
+ yield* owner.remove(j.table, {
203
+ [j.jobId]: job.context.jobId,
204
+ [j.state]: "Confirmed",
205
+ [j.version]: row[j.version],
206
+ });
207
+ owner.postconditions.push(sql`${mapping.clock.engineNowMillis} >= ${until}`);
208
+ removed++;
209
+ }
210
+ }
211
+ if (visited < limit) {
212
+ const candidates = { rows: discovered.grants };
213
+ const remaining = limit - visited;
214
+
215
+ if (candidates.rows.length > remaining) hasMore = true;
216
+ for (const candidate of candidates.rows.slice(0, remaining)) {
217
+ visited++;
218
+ const context = S.tokenContextStorage.decode(candidate[g.context]);
219
+ const client = yield* S.client(mapping, context.configuration, false);
220
+
221
+ invariant(client !== undefined);
222
+ const tuple = yield* F.inspectTuple(mapping, context.identity);
223
+ const cohort = yield* S.cohort(mapping, client.id, tuple.key, false);
224
+
225
+ invariant(cohort.row !== undefined);
226
+ const grant = yield* S.readGrant(mapping, moduleId, context.grantId);
227
+
228
+ if (grant === undefined) continue;
229
+
230
+ const now = yield* owner.now(mapping.clock),
231
+ until = mapping.clock.decodeInstant(grant.row[g.retentionUntil]);
232
+
233
+ if (
234
+ !["Disconnected", "ReauthorizationRequired"].includes(grant.row[g.state]) ||
235
+ grant.row[g.sealed] !== null ||
236
+ grant.row[g.refreshWork] === "Unresolved" ||
237
+ grant.row[g.revocationJobId] !== null ||
238
+ now < until
239
+ )
240
+ continue;
241
+
242
+ const a = mapping.admission,
243
+ d = mapping.command;
244
+
245
+ const references = both(
246
+ sql`not exists(select 1 from ${a.table} where ${equal(a.table, { [a.moduleId]: moduleId, [a.grantId]: context.grantId })})`,
247
+ sql`not exists(select 1 from ${d.table} where ${equal(d.table, { [d.moduleId]: moduleId, [d.grantId]: context.grantId })})`,
248
+ ...(j === undefined
249
+ ? []
250
+ : [
251
+ sql`not exists(select 1 from ${j.table} where ${equal(j.table, { [j.moduleId]: moduleId, [j.grantId]: context.grantId })})`,
252
+ ]),
253
+ );
254
+
255
+ if (!(yield* owner.check(references))) continue;
256
+ yield* owner.remove(g.table, {
257
+ [g.moduleId]: moduleId,
258
+ [g.grantId]: context.grantId,
259
+ [g.version]: grant.row[g.version],
260
+ });
261
+ owner.postconditions.push(references, sql`${mapping.clock.engineNowMillis} >= ${until}`);
262
+ removed++;
263
+ }
264
+ }
265
+ // Clearing is global remote authority, independent of the module which happens
266
+ // to run cleanup. No subject filter may hide a former owner's unresolved work.
267
+ if (visited < limit && j !== undefined) {
268
+ const candidates = { rows: discovered.cohorts };
269
+ const remaining = limit - visited;
270
+
271
+ if (candidates.rows.length > remaining) hasMore = true;
272
+ for (const candidate of candidates.rows.slice(0, remaining)) {
273
+ visited++;
274
+ const id = candidate[h.clientKey];
275
+ const client = yield* S.lockClientById(mapping, id);
276
+ const cohort = yield* S.cohort(mapping, id, candidate[h.identityKey], false);
277
+
278
+ invariant(cohort.id === candidate[h.cohortKey] && cohort.row !== undefined);
279
+ yield* C.clear(mapping, client, cohort);
280
+ }
281
+ }
282
+ if (visited < limit && mapping.externalReference !== undefined) {
283
+ const t = mapping.ownership.tuple,
284
+ remaining = limit - visited;
285
+
286
+ if (discovered.tuples.length > remaining) hasMore = true;
287
+ for (const candidate of discovered.tuples.slice(0, remaining)) {
288
+ visited++;
289
+ const scope = yield* S.heldScope(mapping, candidate[t.provider], candidate[t.issuer]);
290
+
291
+ if (scope === undefined) continue;
292
+
293
+ const identity = {
294
+ provider: candidate[t.provider],
295
+ issuer: candidate[t.issuer],
296
+ subject: candidate[t.externalSubject],
297
+ };
298
+
299
+ invariant((yield* oauthIdentityKey(identity)) === candidate[t.identityKey]);
300
+
301
+ const tuple = yield* F.inspectTuple(mapping, identity),
302
+ row = tuple.row;
303
+
304
+ if (row === undefined || row[t.state] !== "Owned") continue;
305
+ const native = row[t.subjectId];
306
+
307
+ invariant(native !== null);
308
+
309
+ const noRefs = both(
310
+ sql`not (${connectedReferenceCondition(mapping, tuple.key)})`,
311
+ sql`not (${mapping.externalReference({ identityKey: tuple.key, subjectId: S.nativeCopy(native) })})`,
312
+ );
313
+
314
+ if (!(yield* owner.check(noRefs))) continue;
315
+ yield* S.touchScope(mapping, scope);
316
+ yield* owner.update(
317
+ t.table,
318
+ { [t.identityKey]: tuple.key, [t.state]: "Owned", [t.subjectId]: native },
319
+ {
320
+ [t.state]: "Unowned",
321
+ [t.subjectId]: null,
322
+ [t.reservation]: null,
323
+ [t.version]: owner.marker,
324
+ },
325
+ );
326
+ if (mapping.ownership.mode === "separate")
327
+ yield* owner.remove(mapping.ownership.external.table, {
328
+ [mapping.ownership.external.identityKey]: tuple.key,
329
+ });
330
+ owner.postconditions.push(noRefs);
331
+ }
332
+ }
333
+
334
+ return { visited, removed, hasMore };
335
+ });
336
+
337
+ return { discover, collect };
338
+ });
339
+
340
+ export class OAuthConnectedCollection extends Context.Service<
341
+ OAuthConnectedCollection,
342
+ Effect.Success<typeof make>
343
+ >()("effect-auth/persistence/OAuthConnectedCollection") {
344
+ static readonly layer = Layer.effect(OAuthConnectedCollection, make);
345
+ }
@@ -0,0 +1,308 @@
1
+ import * as M from "@yielded/auth/OAuth";
2
+ import { snapshotOAuthSync } from "@yielded/auth/OAuth";
3
+ import { Effect, Context, Layer } from "effect";
4
+
5
+ import type { SqlExpression as SQL } from "../query-operations";
6
+ import { OAuthConnectedState } from "./connected-state";
7
+ import type * as STypes from "./connected-state";
8
+ import { CurrentOAuthTransaction } from "./owner";
9
+ import { OAuthQueryCompiler } from "./query-compiler";
10
+ import { invariant, oauthIdentityKey } from "./state";
11
+
12
+ type Client = NonNullable<Effect.Success<ReturnType<OAuthConnectedState["Service"]["client"]>>>;
13
+
14
+ type Cohort = Effect.Success<ReturnType<OAuthConnectedState["Service"]["cohort"]>>;
15
+
16
+ const make = Effect.gen(function* () {
17
+ const operations = yield* OAuthQueryCompiler;
18
+ const S = yield* OAuthConnectedState;
19
+
20
+ const { eq, isNotNull, ne, sql } = operations;
21
+ const { both, col, equal } = operations;
22
+
23
+ const sameContext = (a: M.OAuthConnectedTokenContext, b: M.OAuthConnectedTokenContext) =>
24
+ S.tokenContextStorage.encode(a) === S.tokenContextStorage.encode(b);
25
+
26
+ const sameGrant = (a: M.OAuthConnectedStoredGrant, b: M.OAuthConnectedStoredGrant) =>
27
+ S.grantStorage.encode(a) === S.grantStorage.encode(b);
28
+
29
+ const summary = (context: M.OAuthConnectedTokenContext) =>
30
+ snapshotOAuthSync(M.OAuthConnectedSummary, {
31
+ grantId: context.grantId,
32
+ provider: context.identity.provider,
33
+ issuer: context.identity.issuer,
34
+ profileKey: context.configuration.profile.key,
35
+ status: "Active",
36
+ scopes: context.metadata.scopes,
37
+ ...(context.metadata.accessExpiresAtMillis === undefined
38
+ ? {}
39
+ : { accessExpiresAtMillis: context.metadata.accessExpiresAtMillis }),
40
+ useUntilMillis: context.metadata.useUntilMillis,
41
+ ...(context.metadata.profile === undefined ? {} : { profile: context.metadata.profile }),
42
+ remoteRevocation:
43
+ context.configuration.profile.revocation === "cohort" ? "Pending" : "Unsupported",
44
+ });
45
+
46
+ /** A generation fences every sibling, including those beyond a bounded metadata page. */
47
+ const fence = Effect.fn("oauthConnected.fence")(function* (
48
+ mapping: STypes.Authority,
49
+ client: Client,
50
+ cohort: Cohort,
51
+ ) {
52
+ const owner = yield* CurrentOAuthTransaction;
53
+
54
+ invariant(cohort.row !== undefined);
55
+
56
+ const g = mapping.grant,
57
+ h = mapping.cohort;
58
+
59
+ const active = both(
60
+ eq(col(g.table, g.cohortKey), cohort.id),
61
+ isNotNull(col(g.table, g.activeIdentityKey)),
62
+ );
63
+
64
+ const affected = yield* S.rowCount(g.table, active);
65
+
66
+ invariant(yield* owner.check(S.countCondition(g.table, active, affected)));
67
+ const cutoff = yield* S.nextOrder(mapping, client);
68
+ const generation = M.OAuthConnectedTarget.fields.cohortGeneration.make(owner.marker);
69
+
70
+ yield* owner.update(
71
+ h.table,
72
+ { [h.cohortKey]: cohort.id },
73
+ {
74
+ [h.generation]: generation,
75
+ [h.cutoff]: mapping.order.encode(cutoff),
76
+ [h.state]: "Blocked",
77
+ [h.version]: owner.marker,
78
+ },
79
+ );
80
+ yield* owner.write(
81
+ owner.database
82
+ .update(g.table)
83
+ .set({
84
+ [g.activeIdentityKey]: null,
85
+ [g.state]: "ReauthorizationRequired",
86
+ [g.version]: owner.marker,
87
+ })
88
+ .where(active),
89
+ );
90
+ for (const observation of owner.observations)
91
+ if (observation.table === g.table)
92
+ observation.rows = observation.rows.map((row) =>
93
+ row[g.cohortKey] === cohort.id && row[g.activeIdentityKey] !== null
94
+ ? {
95
+ ...row,
96
+ [g.activeIdentityKey]: null,
97
+ [g.state]: "ReauthorizationRequired",
98
+ [g.version]: owner.marker,
99
+ }
100
+ : row,
101
+ );
102
+ owner.postconditions.push(sql`not exists(select 1 from ${g.table} where ${active})`);
103
+ cohort.generation = generation;
104
+ cohort.cutoff = cutoff;
105
+ cohort.blocked = true;
106
+
107
+ return affected;
108
+ });
109
+
110
+ const storeJob = Effect.fn("oauthConnected.storeJob")(function* (
111
+ mapping: STypes.Mapping,
112
+ job: M.OAuthConnectedRevocationJob,
113
+ token: M.OAuthConnectedTokenContext,
114
+ native: unknown,
115
+ now: number,
116
+ ) {
117
+ const owner = yield* CurrentOAuthTransaction;
118
+
119
+ invariant(
120
+ mapping.revocation.mode === "cohort" &&
121
+ token.configuration.profile.revocation === "cohort" &&
122
+ sameContext(job.context.token, token),
123
+ );
124
+
125
+ const j = mapping.revocation.job,
126
+ identity = yield* oauthIdentityKey(token.identity),
127
+ client = yield* S.clientKey(token.configuration),
128
+ cohort = yield* S.cohortKey(client, identity);
129
+
130
+ const h = mapping.cohort;
131
+
132
+ owner.postconditions.push(
133
+ sql`exists(select 1 from ${h.table} where ${both(equal(h.table, { [h.cohortKey]: cohort, [h.clientKey]: client, [h.identityKey]: identity, [h.state]: "Blocked" }))})`,
134
+ );
135
+ const key = { [j.jobId]: job.context.jobId };
136
+ const prior = yield* owner.read(j.table, equal(j.table, key), { limit: 1 });
137
+
138
+ if (prior.rows.length) {
139
+ const row = prior.rows[0]!;
140
+
141
+ invariant(
142
+ row[j.snapshot] === S.jobStorage.encode(job) &&
143
+ row[j.moduleId] === token.moduleId &&
144
+ mapping.subjectId.equals(row[j.subjectId], native) &&
145
+ row[j.identityKey] === identity &&
146
+ row[j.clientKey] === client &&
147
+ row[j.cohortKey] === cohort &&
148
+ row[j.grantId] === token.grantId &&
149
+ ["Pending", "Claimed", "Confirmed", "Unknown"].includes(row[j.state]),
150
+ );
151
+ invariant(
152
+ row[j.state] === "Pending"
153
+ ? row[j.claimId] === null && row[j.claimedAt] === null && row[j.claimExpiresAt] === null
154
+ : typeof row[j.claimId] === "string" &&
155
+ mapping.clock.decodeInstant(row[j.claimExpiresAt]) >
156
+ mapping.clock.decodeInstant(row[j.claimedAt]),
157
+ );
158
+
159
+ return;
160
+ }
161
+
162
+ const inserted = yield* owner.insert(
163
+ j.table,
164
+ {
165
+ ...j.encodeInsert({
166
+ job: snapshotOAuthSync(M.OAuthConnectedRevocationJob, job),
167
+ subjectId: S.nativeCopy(native),
168
+ }),
169
+ ...key,
170
+ [j.moduleId]: token.moduleId,
171
+ [j.subjectId]: native,
172
+ [j.identityKey]: identity,
173
+ [j.clientKey]: client,
174
+ [j.cohortKey]: cohort,
175
+ [j.grantId]: token.grantId,
176
+ [j.snapshot]: S.jobStorage.encode(job),
177
+ [j.state]: "Pending",
178
+ [j.claimId]: null,
179
+ [j.claimedAt]: null,
180
+ [j.claimExpiresAt]: null,
181
+ [j.retentionUntil]: mapping.clock.encodeInstant(S.retainedUntil(mapping, now)),
182
+ [j.version]: owner.marker,
183
+ },
184
+ key,
185
+ );
186
+
187
+ prior.rows = inserted.rows;
188
+ });
189
+
190
+ const noJobs = (mapping: STypes.Authority, id: string | SQL, except?: string) => {
191
+ const j = S.jobTable(mapping);
192
+
193
+ return j === undefined
194
+ ? sql`1 = 1`
195
+ : sql`not exists(select 1 from ${j.table} where ${both(eq(col(j.table, j.cohortKey), id), ne(col(j.table, j.state), "Confirmed"), except === undefined ? undefined : ne(col(j.table, j.jobId), except))})`;
196
+ };
197
+
198
+ const clearableCondition = (
199
+ mapping: STypes.Authority,
200
+ cohortId: string | SQL,
201
+ clientId: string | SQL,
202
+ ) => {
203
+ const f = mapping.flow;
204
+
205
+ return both(
206
+ sql`not exists(select 1 from ${f.table} where ${S.unknownWork(mapping, clientId)})`,
207
+ S.cohortWork(mapping, cohortId),
208
+ noJobs(mapping, cohortId),
209
+ );
210
+ };
211
+
212
+ /** Clearing owns a NEW cutoff. Claims made while the remote revoke was pending
213
+ * cannot enter the next generation even if they finish after that revoke. */
214
+ const clear = Effect.fn("oauthConnected.clear")(function* (
215
+ mapping: STypes.Authority,
216
+ client: Client,
217
+ cohort: Cohort,
218
+ confirmedJob?: string,
219
+ provenOpenWithoutNewJob = false,
220
+ ) {
221
+ const owner = yield* CurrentOAuthTransaction;
222
+
223
+ if (
224
+ !cohort.blocked ||
225
+ cohort.row === undefined ||
226
+ (S.jobTable(mapping) === undefined && !provenOpenWithoutNewJob)
227
+ )
228
+ return false;
229
+ const f = mapping.flow;
230
+ const noUnknown = sql`not exists(select 1 from ${f.table} where ${S.unknownWork(mapping, client.id)})`;
231
+ const work = S.cohortWork(mapping, cohort.id);
232
+
233
+ if (!(yield* owner.check(both(noUnknown, work, noJobs(mapping, cohort.id, confirmedJob)))))
234
+ return false;
235
+
236
+ const cutoff = yield* S.nextOrder(mapping, client),
237
+ h = mapping.cohort;
238
+
239
+ const generation = M.OAuthConnectedTarget.fields.cohortGeneration.make(owner.marker + ":open");
240
+
241
+ yield* owner.update(
242
+ h.table,
243
+ { [h.cohortKey]: cohort.id },
244
+ {
245
+ [h.generation]: generation,
246
+ [h.cutoff]: mapping.order.encode(cutoff),
247
+ [h.state]: "Open",
248
+ [h.version]: owner.marker,
249
+ },
250
+ );
251
+ owner.postconditions.push(noUnknown, work, noJobs(mapping, cohort.id));
252
+ cohort.generation = generation;
253
+ cohort.cutoff = cutoff;
254
+ cohort.blocked = false;
255
+
256
+ return true;
257
+ });
258
+
259
+ /** Former local owners remain visible. A foreign historical dependency cannot be
260
+ * ignored simply because the global tuple was changed outside this authority. */
261
+ const noFormerOwner = (
262
+ mapping: STypes.Authority,
263
+ identity: string,
264
+ client: string,
265
+ native: unknown,
266
+ ) =>
267
+ Effect.gen(function* () {
268
+ const owner = yield* CurrentOAuthTransaction;
269
+
270
+ const g = mapping.grant,
271
+ f = mapping.flow,
272
+ j = S.jobTable(mapping);
273
+
274
+ const condition = both(
275
+ sql`not exists(select 1 from ${g.table} where ${both(eq(col(g.table, g.identityKey), identity), sql`not (${owner.exact(g.table, { [g.subjectId]: native })})`)})`,
276
+ sql`not exists(select 1 from ${f.table} where ${both(eq(col(f.table, f.cohortKey), yield* S.cohortKey(client, identity)), eq(col(f.table, f.work), "Unresolved"), sql`not (${owner.exact(f.table, { [f.subjectId]: native })})`)})`,
277
+ ...(j === undefined
278
+ ? []
279
+ : [
280
+ sql`not exists(select 1 from ${j.table} where ${both(eq(col(j.table, j.identityKey), identity), sql`not (${owner.exact(j.table, { [j.subjectId]: native })})`)})`,
281
+ ]),
282
+ );
283
+
284
+ return yield* Effect.map(owner.check(condition), (accepted) => {
285
+ if (accepted) owner.postconditions.push(condition);
286
+
287
+ return accepted;
288
+ });
289
+ });
290
+
291
+ return {
292
+ sameContext,
293
+ sameGrant,
294
+ summary,
295
+ fence,
296
+ storeJob,
297
+ clearableCondition,
298
+ clear,
299
+ noFormerOwner,
300
+ };
301
+ });
302
+
303
+ export class OAuthConnectedCustody extends Context.Service<
304
+ OAuthConnectedCustody,
305
+ Effect.Success<typeof make>
306
+ >()("effect-auth/persistence/OAuthConnectedCustody") {
307
+ static readonly layer = Layer.effect(OAuthConnectedCustody, make);
308
+ }