@yielded/auth-persistence 0.1.0-beta.29 → 0.1.0-beta.30

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.
@@ -8,7 +8,7 @@ import type { Fragment } from "effect/sql/Statement";
8
8
  import { type OAuthAccountsMapping, OAuthEligibilityFact } from "../models/oauth-model";
9
9
  import type { NativeSqlTables } from "../native-sql-table";
10
10
  import { makeConditionalSqlInsert } from "../session-native-record";
11
- import { anySqlCondition, exactSqlText } from "../sql-change";
11
+ import { anySqlCondition, exactSqlText, sqlTextBytes } from "../sql-change";
12
12
  import { makeSqlCommitExecutor, SqlBatchCommit, CurrentSqlCommit } from "../sql-commit";
13
13
  import type { SqlExpression, TableModel } from "../table-model";
14
14
  import { makeOAuthNativeFlow } from "./native-flow";
@@ -86,6 +86,77 @@ export const makeNativeOAuthAccountsServices = Effect.fnUntraced(function* (
86
86
  ]);
87
87
 
88
88
  const service: M.OAuthAccountsPersistence["Service"] = {
89
+ list: (original) =>
90
+ read(
91
+ Effect.gen(function* () {
92
+ const input = M.snapshotOAuthSync(M.OAuthLinkedAccountsRead, original);
93
+ const nativeId = yield* mapping.subjectId.toNative(input.invocation.subjectId);
94
+
95
+ const metadataAccess = tables.expression(
96
+ mapping.metadataAccess({
97
+ invocation: input.invocation,
98
+ moduleId: input.moduleId,
99
+ subjectId: nativeId,
100
+ }),
101
+ );
102
+
103
+ const s = mapping.subject;
104
+ const orderedId = yield* sqlTextBytes(credential.column(c.credentialId));
105
+
106
+ const cursorCondition =
107
+ input.cursor === undefined
108
+ ? sql``
109
+ : sql`and ${orderedId} > ${yield* sqlTextBytes(credential.value(c.credentialId, input.cursor))}`;
110
+
111
+ // Bound candidate work before resolving identities. Each final read uses
112
+ // its columns' own codecs and rechecks live ownership/authority/policy.
113
+ // A removed candidate can leave a short page; the cursor still advances.
114
+ // Match exact credential identity even when the column's collation does not.
115
+ const rows = yield* sql`select ${credential.fields("page_")} from ${credential.name}
116
+ where ${exactSqlText(sql, credential.column(c.moduleId), credential.value(c.moduleId, input.moduleId))}
117
+ and ${credential.column(c.subjectId)} = ${credential.value(c.subjectId, nativeId)}
118
+ and ${tables.expression(c.activeCondition)} and ${metadataAccess}
119
+ and exists(select 1 from ${state.subject.name}
120
+ where ${state.subject.column(s.id)} = ${state.subject.value(s.id, nativeId)}
121
+ and ${tables.expression(s.activeCondition)})
122
+ ${cursorCondition}
123
+ order by ${orderedId} limit ${input.limit + 1}`;
124
+
125
+ const selected = rows.slice(0, input.limit);
126
+ const items: Array<M.OAuthLinkedAccount> = [];
127
+ let cursor: string | undefined;
128
+
129
+ for (const row of selected) {
130
+ const credentialId = yield* Schema.decodeUnknownEffect(
131
+ M.OAuthLinkedAccount.fields.credentialId,
132
+ )(credential.decode(row, "page_")[c.credentialId]);
133
+
134
+ cursor = credentialId;
135
+
136
+ const current = yield* state.readCredential(
137
+ {
138
+ moduleId: input.moduleId,
139
+ subjectId: input.invocation.subjectId,
140
+ credentialId,
141
+ },
142
+ metadataAccess,
143
+ );
144
+
145
+ if (current !== undefined)
146
+ items.push(
147
+ M.OAuthLinkedAccount.make({
148
+ credentialId: current.credentialId,
149
+ ...current.identity,
150
+ }),
151
+ );
152
+ }
153
+
154
+ return M.snapshotOAuthSync(M.OAuthLinkedAccountsListResult, {
155
+ items,
156
+ ...(rows.length > input.limit && cursor !== undefined ? { cursor } : {}),
157
+ });
158
+ }),
159
+ ),
89
160
  capture: (input) =>
90
161
  read(Effect.map(state.capture(input.subjectId, false), (current) => current?.revision)),
91
162
  issue: (original, prepare) =>
@@ -7,6 +7,7 @@ import {
7
7
  import type { SubjectId } from "@yielded/auth/Schema";
8
8
  import { Effect, Schema } from "effect";
9
9
  import { SqlClient } from "effect/sql";
10
+ import type { Fragment } from "effect/sql/Statement";
10
11
 
11
12
  import type { OAuthSignInMapping } from "../models/oauth-model";
12
13
  import type { NativeSqlTables } from "../native-sql-table";
@@ -305,7 +306,10 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
305
306
  return result;
306
307
  });
307
308
 
308
- const readCredential = Effect.fnUntraced(function* (input: typeof OAuthCredentialKey.Type) {
309
+ const readCredential = Effect.fnUntraced(function* (
310
+ input: typeof OAuthCredentialKey.Type,
311
+ metadataAccess: Fragment = sql`true`,
312
+ ) {
309
313
  const nativeId = yield* mapping.subjectId.toNative(input.subjectId);
310
314
  let identityKey: unknown;
311
315
 
@@ -334,7 +338,7 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
334
338
  and ${owner(o, mapping.ownership.subjectId)}
335
339
  join ${active("subject", "oauth_subject")} on ${owner(s, mapping.subject.id)}
336
340
  join ${active("authority", "oauth_authority")} on ${owner(a, mapping.authority.subjectId)}
337
- where ${owner(c, mapping.credential.subjectId)}
341
+ where ${metadataAccess} and ${owner(c, mapping.credential.subjectId)}
338
342
  and ${exact(c.column(mapping.credential.moduleId), c.value(mapping.credential.moduleId, input.moduleId))}
339
343
  and ${exact(c.column(mapping.credential.credentialId), c.value(mapping.credential.credentialId, input.credentialId))}
340
344
  order by ${a.column(mapping.authority.credentialId)} limit 65`;
@@ -1,5 +1,5 @@
1
1
  import { Effect, Schema } from "effect";
2
- import type { SqlClient } from "effect/sql/SqlClient";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
3
  import type { SqlError } from "effect/sql/SqlError";
4
4
  import { join, type Fragment } from "effect/sql/Statement";
5
5
 
@@ -25,6 +25,19 @@ export const anySqlCondition: (conditions: ReadonlyArray<Fragment>) => Fragment
25
25
  "1 = 0",
26
26
  );
27
27
 
28
+ /** Exact text comparison and ordering, independent of the column's collation. */
29
+ export const sqlTextBytes = Effect.fnUntraced(function* (
30
+ value: Fragment,
31
+ ): Effect.fn.Return<Fragment, never, SqlClient> {
32
+ const sql = (yield* SqlClient).withoutTransforms();
33
+
34
+ return sql.onDialectOrElse({
35
+ mysql: () => sql`binary ${value}`,
36
+ pg: () => sql`convert_to(cast(${value} as text), 'UTF8')`,
37
+ orElse: () => sql`cast(${value} as blob)`,
38
+ });
39
+ });
40
+
28
41
  /** Keep the indexed predicate and reject collation aliases of security tokens. */
29
42
  export const exactSqlText = (sql: SqlClient, left: Fragment, right: Fragment) =>
30
43
  sql.and([