@yielded/auth-persistence 0.1.0-beta.30 → 0.1.0-beta.32
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/README.md +11 -7
- package/dist/Adapter.d.mts +1 -1
- package/dist/Adapter.mjs +8 -8
- package/dist/OAuthPersistence.mjs +10 -5
- package/dist/OAuthPersistence.mjs.map +1 -1
- package/dist/internal/configuration.d.mts +49 -13
- package/dist/internal/configuration.mjs.map +1 -1
- package/dist/internal/email-native.mjs +10 -2
- package/dist/internal/email-native.mjs.map +1 -1
- package/dist/internal/email-policy.mjs +3 -2
- package/dist/internal/email-policy.mjs.map +1 -1
- package/dist/internal/models/oauth-model.d.mts +4 -2
- package/dist/internal/models/oauth-model.mjs.map +1 -1
- package/dist/internal/models/password-model.d.mts +2 -1
- package/dist/internal/models/password-model.mjs.map +1 -1
- package/dist/internal/oauth/native-accounts.d.mts +1 -1
- package/dist/internal/oauth/native-accounts.mjs +6 -6
- package/dist/internal/oauth/native-accounts.mjs.map +1 -1
- package/dist/internal/oauth/native-connected.d.mts +1 -1
- package/dist/internal/oauth/native-connected.mjs +1 -1
- package/dist/internal/oauth/native-registration.mjs +19 -8
- package/dist/internal/oauth/native-registration.mjs.map +1 -1
- package/dist/internal/oauth/native-sign-in.d.mts +1 -1
- package/dist/internal/oauth/native-sign-in.mjs +1 -1
- package/dist/internal/oauth/native-state.mjs +21 -11
- package/dist/internal/oauth/native-state.mjs.map +1 -1
- package/dist/internal/oauth/state.d.mts +1 -1
- package/dist/internal/oauth/state.mjs +1 -1
- package/dist/internal/oauth-managed.mjs +182 -0
- package/dist/internal/oauth-managed.mjs.map +1 -0
- package/dist/internal/passkey-managed.mjs +5 -3
- package/dist/internal/passkey-managed.mjs.map +1 -1
- package/dist/internal/passkey-native-registration.mjs +1 -1
- package/dist/internal/passkey-native-state.mjs +33 -23
- package/dist/internal/passkey-native-state.mjs.map +1 -1
- package/dist/internal/passkey-native-write.mjs +3 -3
- package/dist/internal/passkey-native-write.mjs.map +1 -1
- package/dist/internal/passkey-native.d.mts +15 -7
- package/dist/internal/passkey-native.mjs +2 -1
- package/dist/internal/passkey-native.mjs.map +1 -1
- package/dist/internal/passkey-policy.mjs +9 -7
- package/dist/internal/passkey-policy.mjs.map +1 -1
- package/dist/internal/password-native.mjs +22 -5
- package/dist/internal/password-native.mjs.map +1 -1
- package/dist/internal/password-policy.mjs +7 -6
- package/dist/internal/password-policy.mjs.map +1 -1
- package/dist/internal/password-registration-native.d.mts +1 -1
- package/dist/internal/persistence.d.mts +3 -0
- package/dist/internal/persistence.mjs +99 -42
- package/dist/internal/persistence.mjs.map +1 -1
- package/dist/internal/phone-native.mjs +1 -1
- package/dist/internal/phone-native.mjs.map +1 -1
- package/dist/internal/registration.mjs +17 -8
- package/dist/internal/registration.mjs.map +1 -1
- package/dist/internal/session-native-authority.mjs +8 -6
- package/dist/internal/session-native-authority.mjs.map +1 -1
- package/dist/internal/session-native-login.d.mts +2 -2
- package/dist/internal/session-native-login.mjs +4 -2
- package/dist/internal/session-native-login.mjs.map +1 -1
- package/dist/internal/session-native-pending-read.mjs +26 -20
- package/dist/internal/session-native-pending-read.mjs.map +1 -1
- package/dist/internal/session-native-record.mjs +6 -4
- package/dist/internal/session-native-record.mjs.map +1 -1
- package/dist/internal/session-native-state.mjs +24 -8
- package/dist/internal/session-native-state.mjs.map +1 -1
- package/dist/internal/session-native-stateful.d.mts +2 -2
- package/dist/internal/session-native-stateful.mjs +67 -38
- package/dist/internal/session-native-stateful.mjs.map +1 -1
- package/dist/internal/session-native-step-up.d.mts +2 -2
- package/dist/internal/session-native-step-up.mjs +17 -19
- package/dist/internal/session-native-step-up.mjs.map +1 -1
- package/dist/internal/session-native-validity.mjs +3 -1
- package/dist/internal/session-native-validity.mjs.map +1 -1
- package/dist/internal/sql-authority.mjs +5 -1
- package/dist/internal/sql-authority.mjs.map +1 -1
- package/dist/internal/sql-storage-validation.mjs +2 -1
- package/dist/internal/sql-storage-validation.mjs.map +1 -1
- package/dist/internal/sql-table.d.mts +1 -0
- package/dist/internal/sql-table.mjs.map +1 -1
- package/dist/internal/sqlite-policy-guard.mjs +23 -0
- package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
- package/dist/internal/step-up-state.mjs +19 -6
- package/dist/internal/step-up-state.mjs.map +1 -1
- package/dist/internal/storage-mapping.d.mts +9 -5
- package/dist/internal/storage-mapping.mjs +17 -5
- package/dist/internal/storage-mapping.mjs.map +1 -1
- package/dist/internal/storage-tables.d.mts +237 -0
- package/dist/internal/storage-tables.mjs +68 -0
- package/dist/internal/storage-tables.mjs.map +1 -1
- package/dist/internal/storage-validation.d.mts +3 -1
- package/dist/internal/storage-validation.mjs +70 -17
- package/dist/internal/storage-validation.mjs.map +1 -1
- package/dist/internal/totp-native.mjs +1 -1
- package/package.json +1 -1
- package/src/OAuthPersistence.ts +7 -2
- package/src/internal/configuration.ts +72 -15
- package/src/internal/email-native.ts +16 -6
- package/src/internal/email-policy.ts +12 -3
- package/src/internal/models/oauth-model.ts +7 -1
- package/src/internal/models/password-model.ts +2 -1
- package/src/internal/oauth/native-accounts.ts +3 -3
- package/src/internal/oauth/native-registration.ts +17 -6
- package/src/internal/oauth/native-state.ts +38 -18
- package/src/internal/oauth-managed.ts +277 -0
- package/src/internal/passkey-managed.ts +8 -2
- package/src/internal/passkey-native-state.ts +59 -52
- package/src/internal/passkey-native-write.ts +2 -2
- package/src/internal/passkey-native.ts +4 -2
- package/src/internal/passkey-policy.ts +13 -9
- package/src/internal/password-native.ts +45 -11
- package/src/internal/password-policy.ts +16 -8
- package/src/internal/persistence.ts +360 -204
- package/src/internal/phone-native.ts +1 -1
- package/src/internal/registration.ts +53 -40
- package/src/internal/session-native-authority.ts +26 -8
- package/src/internal/session-native-login.ts +9 -4
- package/src/internal/session-native-pending-read.ts +46 -33
- package/src/internal/session-native-record.ts +6 -4
- package/src/internal/session-native-state.ts +32 -10
- package/src/internal/session-native-stateful.ts +147 -52
- package/src/internal/session-native-step-up.ts +31 -24
- package/src/internal/session-native-validity.ts +3 -1
- package/src/internal/sql-authority.ts +5 -1
- package/src/internal/sql-storage-validation.ts +53 -46
- package/src/internal/sql-table.ts +1 -0
- package/src/internal/sqlite-policy-guard.ts +34 -0
- package/src/internal/step-up-state.ts +43 -6
- package/src/internal/storage-mapping.ts +40 -4
- package/src/internal/storage-tables.ts +76 -0
- package/src/internal/storage-validation.ts +127 -27
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Array, Effect } from "effect";
|
|
2
|
+
import { SqlClient } from "effect/sql/SqlClient";
|
|
3
|
+
import type { Fragment } from "effect/sql/Statement";
|
|
4
|
+
|
|
5
|
+
import { appendSqlBatchStatement, registerSqlBatchPostcondition } from "./sql-commit";
|
|
6
|
+
|
|
7
|
+
export interface SqlitePolicyValue {
|
|
8
|
+
readonly column: Fragment;
|
|
9
|
+
readonly value: Fragment;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Check original values before writes and at the end of the same atomic batch.
|
|
13
|
+
* Small groups bound parameters and expression depth without expanding retained
|
|
14
|
+
* values into hex/JSON. BINARY preserves text bytes; IS also compares NULLs. */
|
|
15
|
+
export const guardSqlitePolicy = Effect.fnUntraced(function* (input: {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly table: Fragment;
|
|
18
|
+
readonly owner: Fragment;
|
|
19
|
+
readonly values: ReadonlyArray<SqlitePolicyValue>;
|
|
20
|
+
}) {
|
|
21
|
+
const sql = (yield* SqlClient).withoutTransforms();
|
|
22
|
+
|
|
23
|
+
for (const values of Array.chunksOf(input.values, 32)) {
|
|
24
|
+
const matches = sql.and(
|
|
25
|
+
values.map(({ column, value }) => sql`${column} collate binary is ${value}`),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const condition = sql`exists(select 1 from ${input.table} where ${input.owner} and ${matches})`;
|
|
29
|
+
const statement = sql`select case when ${condition} then 1 else json_extract('[]', '$[auth-batch-precondition]') end as valid`;
|
|
30
|
+
|
|
31
|
+
yield* appendSqlBatchStatement(statement);
|
|
32
|
+
yield* registerSqlBatchPostcondition({ name: input.name, statement });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AuthenticationAssurance, AuthenticationClock } from "@yielded/auth/Operations";
|
|
1
2
|
import {
|
|
2
3
|
SessionStepUpInvalid,
|
|
3
4
|
SessionUnavailable,
|
|
4
5
|
AuthenticationEvidence,
|
|
6
|
+
AuthenticationProof,
|
|
5
7
|
SessionMetadata,
|
|
6
8
|
SessionStepUpIntent,
|
|
7
9
|
SessionStepUpRequirement,
|
|
@@ -10,7 +12,7 @@ import {
|
|
|
10
12
|
} from "@yielded/auth/Sessions";
|
|
11
13
|
import { DateTime, Effect, Schema } from "effect";
|
|
12
14
|
|
|
13
|
-
import { sameSessionRevision } from "./session-native-state";
|
|
15
|
+
import { assessSessionAt, sameSessionRevision } from "./session-native-state";
|
|
14
16
|
|
|
15
17
|
export const stepUpIntentCodec = Schema.fromJsonString(SessionStepUpIntent);
|
|
16
18
|
const BoundedSnapshot = Schema.String.check(Schema.isMaxLength(131072));
|
|
@@ -27,11 +29,13 @@ export const decodeStepUpIntent = (snapshot: string) =>
|
|
|
27
29
|
Effect.mapError(() => SessionUnavailable.make({})),
|
|
28
30
|
);
|
|
29
31
|
|
|
30
|
-
export const stepUpIntentLive = (
|
|
32
|
+
export const stepUpIntentLive = Effect.fnUntraced(function* (
|
|
31
33
|
intent: Omit<SessionStepUpIntent, "version">,
|
|
32
34
|
kind: SessionStepUpIntent["sourceKind"],
|
|
33
35
|
now: DateTime.Utc,
|
|
34
|
-
)
|
|
36
|
+
): Effect.fn.Return<boolean> {
|
|
37
|
+
const { futureToleranceMillis } = yield* AuthenticationClock;
|
|
38
|
+
|
|
35
39
|
const n = DateTime.toEpochMillis(now),
|
|
36
40
|
authenticated = DateTime.toEpochMillis(intent.sourceAuthenticatedAt),
|
|
37
41
|
idle = DateTime.toEpochMillis(intent.sourceExpiresAt),
|
|
@@ -40,16 +44,22 @@ export const stepUpIntentLive = (
|
|
|
40
44
|
|
|
41
45
|
return (
|
|
42
46
|
intent.sourceKind === kind &&
|
|
43
|
-
authenticated
|
|
47
|
+
authenticated - n <= futureToleranceMillis &&
|
|
48
|
+
authenticated < idle &&
|
|
44
49
|
n < expires &&
|
|
45
50
|
expires <= idle &&
|
|
46
51
|
idle <= absolute
|
|
47
52
|
);
|
|
48
|
-
};
|
|
53
|
+
});
|
|
49
54
|
|
|
50
55
|
const evidenceEncoding = Schema.encodeSync(Schema.fromJsonString(AuthenticationEvidence));
|
|
51
56
|
const requirementEncoding = Schema.encodeSync(Schema.fromJsonString(SessionStepUpRequirement));
|
|
52
57
|
const metadataEncoding = Schema.encodeSync(Schema.fromJsonString(SessionMetadata));
|
|
58
|
+
const proofEncoding = Schema.encodeSync(Schema.fromJsonString(Schema.Array(AuthenticationProof)));
|
|
59
|
+
|
|
60
|
+
const assuranceEncoding = Schema.encodeSync(
|
|
61
|
+
Schema.fromJsonString(SessionMetadata.fields.assurance),
|
|
62
|
+
);
|
|
53
63
|
|
|
54
64
|
/** Validate core-owned plans before driver allocation/SQL expansion. No bearer or Claims decoding. */
|
|
55
65
|
export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(function* <Claims>(
|
|
@@ -57,7 +67,14 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
|
|
|
57
67
|
) {
|
|
58
68
|
const { intent, source, replacement, evidence } = plan;
|
|
59
69
|
const original = source.inspection;
|
|
70
|
+
const issuedAt = DateTime.toEpochMillis(replacement.inspection.session.issuedAt);
|
|
60
71
|
|
|
72
|
+
if (
|
|
73
|
+
issuedAt < DateTime.toEpochMillis(original.session.issuedAt) ||
|
|
74
|
+
evidence.proofs.some((proof) => DateTime.toEpochMillis(proof.verifiedAt) > issuedAt) ||
|
|
75
|
+
issuedAt >= DateTime.toEpochMillis(replacement.inspection.session.expiresAt)
|
|
76
|
+
)
|
|
77
|
+
return yield* SessionStepUpInvalid.make({});
|
|
61
78
|
if (
|
|
62
79
|
requirementEncoding(plan.profileRequirement) !== requirementEncoding(intent.requirement) ||
|
|
63
80
|
replacement.inspection.session.subjectId !== evidence.revision.subjectId ||
|
|
@@ -89,7 +106,9 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
|
|
|
89
106
|
DateTime.toEpochMillis(original.session.absoluteExpiresAt) ||
|
|
90
107
|
DateTime.toEpochMillis(intent.sourceAbsoluteExpiresAt) !==
|
|
91
108
|
DateTime.toEpochMillis(replacement.inspection.session.absoluteExpiresAt) ||
|
|
92
|
-
evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence)
|
|
109
|
+
evidenceEncoding(replacement.inspection.provenance.evidence) !== evidenceEncoding(evidence) ||
|
|
110
|
+
proofEncoding(evidence.proofs.slice(0, original.provenance.evidence.proofs.length)) !==
|
|
111
|
+
proofEncoding(original.provenance.evidence.proofs)
|
|
93
112
|
)
|
|
94
113
|
return yield* SessionStepUpInvalid.make({});
|
|
95
114
|
if (
|
|
@@ -111,6 +130,24 @@ export const validateStepUpPlan = Effect.fn("DrizzleStepUp.validatePlan")(functi
|
|
|
111
130
|
replacement.inspection.session.sessionId === intent.sourceSessionId
|
|
112
131
|
)
|
|
113
132
|
return yield* SessionStepUpInvalid.make({});
|
|
133
|
+
|
|
134
|
+
const at = DateTime.toEpochMillis(plan.now);
|
|
135
|
+
const base = yield* assessSessionAt(evidence, plan.baseRequirement, at);
|
|
136
|
+
|
|
137
|
+
const profile = yield* assessSessionAt(evidence, plan.profileRequirement, at);
|
|
138
|
+
|
|
139
|
+
// Freshness can change metadata at commit; validate the exact preparation snapshot here.
|
|
140
|
+
if (
|
|
141
|
+
!base.satisfied ||
|
|
142
|
+
!profile.satisfied ||
|
|
143
|
+
assuranceEncoding(
|
|
144
|
+
AuthenticationAssurance.make({
|
|
145
|
+
...base.assurance,
|
|
146
|
+
authenticatedAt: profile.assurance.authenticatedAt,
|
|
147
|
+
}),
|
|
148
|
+
) !== assuranceEncoding(replacement.inspection.session.assurance)
|
|
149
|
+
)
|
|
150
|
+
return yield* SessionStepUpInvalid.make({});
|
|
114
151
|
});
|
|
115
152
|
|
|
116
153
|
/** Compare a consumer-decoded encoded update/readback with the intended rotation.
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type StatefulSessionRecord,
|
|
17
17
|
} from "@yielded/auth/Sessions";
|
|
18
18
|
import { Crypto, DateTime, Effect, Redacted, Schema, Struct } from "effect";
|
|
19
|
+
import { SqlClient } from "effect/sql";
|
|
19
20
|
|
|
20
21
|
import type { MappingInput } from "./configuration";
|
|
21
22
|
import { randomId } from "./crypto";
|
|
@@ -27,6 +28,8 @@ import {
|
|
|
27
28
|
} from "./models/password-model";
|
|
28
29
|
import { requiredProofConstraints, type AnyProofPersistenceMapping } from "./models/proof-model";
|
|
29
30
|
import type { StatefulSessionMapping, PendingAuthenticationMapping } from "./models/session-model";
|
|
31
|
+
import type { NativeSqlTables } from "./native-sql-table";
|
|
32
|
+
import { exactSqlText } from "./sql-change";
|
|
30
33
|
import { makeStorageClock } from "./storage-clock";
|
|
31
34
|
import { storageTables, type StorageRole } from "./storage-tables";
|
|
32
35
|
import type { TableModel, SqlExpression } from "./table-model";
|
|
@@ -40,12 +43,16 @@ const string = (value: unknown) => Schema.decodeUnknownSync(Schema.String)(value
|
|
|
40
43
|
|
|
41
44
|
/** Derive shared row mappings from a managed or custom storage layout.
|
|
42
45
|
* Acquire Effect Crypto once; the returned allocators retain that implementation.
|
|
43
|
-
* Each factory requires its role tables to exist.
|
|
44
|
-
*
|
|
45
|
-
* authority policy for explicit services.
|
|
46
|
+
* Each factory requires its role tables to exist. Supplying native tables derives
|
|
47
|
+
* batch commit predicates and subject policy columns. Adapter authors may refine
|
|
48
|
+
* clocks and authority policy for explicit services.
|
|
46
49
|
*/
|
|
47
|
-
export const makeMappings = Effect.fnUntraced(function* (
|
|
50
|
+
export const makeMappings = Effect.fnUntraced(function* (
|
|
51
|
+
input: MappingInput,
|
|
52
|
+
tables?: NativeSqlTables,
|
|
53
|
+
) {
|
|
48
54
|
const crypto = yield* Crypto.Crypto;
|
|
55
|
+
const sql = (yield* SqlClient.SqlClient).withoutTransforms();
|
|
49
56
|
|
|
50
57
|
const allocate = randomId.pipe(
|
|
51
58
|
Effect.provideService(Crypto.Crypto, crypto),
|
|
@@ -67,12 +74,27 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
|
|
|
67
74
|
|
|
68
75
|
const { subjects: s, encodeInstant: instant, decodeInstant: readInstant } = input;
|
|
69
76
|
const subjectId = { toNative: s.toNative, toSubject: s.toSubject, equals: Object.is };
|
|
77
|
+
const identifierTable = tables?.(table("identifiers"));
|
|
78
|
+
|
|
79
|
+
const activeIdentifier =
|
|
80
|
+
identifierTable === undefined
|
|
81
|
+
? undefined
|
|
82
|
+
: sql`${identifierTable.column("active")} = ${identifierTable.value("active", true)}`;
|
|
70
83
|
|
|
71
84
|
const subject = {
|
|
72
85
|
table: s.table,
|
|
73
86
|
id: s.id,
|
|
74
87
|
status: s.status,
|
|
75
88
|
securityRevision: s.securityRevision,
|
|
89
|
+
// Policy callbacks may read any application column. Identity, status and
|
|
90
|
+
// security revision have their own final checks and can be intentionally revised.
|
|
91
|
+
...(tables === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: {
|
|
94
|
+
requirementColumns: tables(s.table).keys.filter(
|
|
95
|
+
(key) => key !== s.id && key !== s.status && key !== s.securityRevision,
|
|
96
|
+
),
|
|
97
|
+
}),
|
|
76
98
|
isActiveStatus: (value: unknown) => Object.is(value, s.activeValue),
|
|
77
99
|
d1ActiveStatusValue: s.activeValue,
|
|
78
100
|
activeStatusValue: s.activeValue,
|
|
@@ -88,6 +110,7 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
|
|
|
88
110
|
revision: "revision",
|
|
89
111
|
status: "active",
|
|
90
112
|
activeStatusValue: true,
|
|
113
|
+
d1ActiveStatusValue: true,
|
|
91
114
|
isActiveStatus: (value: unknown) => value === true,
|
|
92
115
|
encodeInsert: (row: object) => ({ ...row, active: true }),
|
|
93
116
|
encodeRevision: (revision: SecurityRevision) => ({ revision }),
|
|
@@ -142,6 +165,11 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
|
|
|
142
165
|
verifiedAt: "verifiedAt",
|
|
143
166
|
bindingRevision: "revision",
|
|
144
167
|
isCurrent: (row) => row.active === true,
|
|
168
|
+
...(activeIdentifier === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: {
|
|
171
|
+
d1CurrentCondition: () => activeIdentifier,
|
|
172
|
+
}),
|
|
145
173
|
encodeInitialInsert: (identifier, subjectId, revision) => ({
|
|
146
174
|
...identifier,
|
|
147
175
|
subjectId,
|
|
@@ -230,6 +258,14 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
|
|
|
230
258
|
bindingRevision: "revision",
|
|
231
259
|
isCurrent: (row) => row.active === true && row.verifiedAt !== null,
|
|
232
260
|
isMutableTarget: (row) => row.active === true && row.verifiedAt === null,
|
|
261
|
+
...(identifierTable === undefined || activeIdentifier === undefined
|
|
262
|
+
? {}
|
|
263
|
+
: {
|
|
264
|
+
d1CurrentCondition: ({ bindingRevision }) =>
|
|
265
|
+
sql`${activeIdentifier} and ${identifierTable.column("verifiedAt")} is not null and ${exactSqlText(sql, identifierTable.column("revision"), identifierTable.value("revision", bindingRevision))}`,
|
|
266
|
+
d1MutableTargetCondition: () =>
|
|
267
|
+
sql`${activeIdentifier} and ${identifierTable.column("verifiedAt")} is null`,
|
|
268
|
+
}),
|
|
233
269
|
encodeVerifiedInsert: ({ identifier, subjectId, verifiedAtMillis, bindingRevision }) => ({
|
|
234
270
|
...identifier,
|
|
235
271
|
subjectId,
|
|
@@ -27,6 +27,27 @@ const spec = <C extends Readonly<Record<string, Omit<StorageTable["columns"][str
|
|
|
27
27
|
unique: ReadonlyArray<ReadonlyArray<Extract<keyof C, string>>>,
|
|
28
28
|
) => ({ columns, unique });
|
|
29
29
|
|
|
30
|
+
const oauthFlow = spec(
|
|
31
|
+
{
|
|
32
|
+
moduleId: text,
|
|
33
|
+
flowId: text,
|
|
34
|
+
purpose: text,
|
|
35
|
+
generation: integer,
|
|
36
|
+
provider: text,
|
|
37
|
+
callbackId: text,
|
|
38
|
+
issuer: text,
|
|
39
|
+
responseIssuerMode: text,
|
|
40
|
+
subjectId: optionalText,
|
|
41
|
+
stateDigest: text,
|
|
42
|
+
binderVerifier: text,
|
|
43
|
+
binderExpiresAt: integer,
|
|
44
|
+
snapshot: text,
|
|
45
|
+
issuedAt: integer,
|
|
46
|
+
expiresAt: integer,
|
|
47
|
+
},
|
|
48
|
+
[["moduleId", "flowId"], ["stateDigest"]],
|
|
49
|
+
);
|
|
50
|
+
|
|
30
51
|
/** Logical roles are independent of table names and migration ownership. */
|
|
31
52
|
export const storageTables = {
|
|
32
53
|
subjects: spec({ id: text, active: boolean, securityRevision: text }, [["id"]]),
|
|
@@ -113,6 +134,61 @@ export const storageTables = {
|
|
|
113
134
|
},
|
|
114
135
|
[["moduleId", "flowId"]],
|
|
115
136
|
),
|
|
137
|
+
oauthIdentities: spec(
|
|
138
|
+
{ identityKey: text, provider: text, issuer: text, externalSubject: text, subjectId: text },
|
|
139
|
+
[["identityKey"]],
|
|
140
|
+
),
|
|
141
|
+
oauthCredentials: spec(
|
|
142
|
+
{
|
|
143
|
+
moduleId: text,
|
|
144
|
+
credentialId: text,
|
|
145
|
+
subjectId: text,
|
|
146
|
+
identityKey: text,
|
|
147
|
+
credentialRevision: text,
|
|
148
|
+
active: boolean,
|
|
149
|
+
},
|
|
150
|
+
[["credentialId"], ["identityKey"]],
|
|
151
|
+
),
|
|
152
|
+
oauthSignInFlows: oauthFlow,
|
|
153
|
+
oauthConnectedFlows: oauthFlow,
|
|
154
|
+
oauthConnectedGrants: spec(
|
|
155
|
+
{
|
|
156
|
+
moduleId: text,
|
|
157
|
+
grantId: text,
|
|
158
|
+
subjectId: text,
|
|
159
|
+
identityKey: text,
|
|
160
|
+
profileKey: text,
|
|
161
|
+
grantVersion: text,
|
|
162
|
+
tokenVersion: text,
|
|
163
|
+
state: text,
|
|
164
|
+
snapshot: text,
|
|
165
|
+
summary: text,
|
|
166
|
+
refreshClaimId: optionalText,
|
|
167
|
+
refreshNextTokenVersion: optionalText,
|
|
168
|
+
refreshClaimedAt: optionalInteger,
|
|
169
|
+
refreshClaimExpiresAt: optionalInteger,
|
|
170
|
+
expiresAt: integer,
|
|
171
|
+
},
|
|
172
|
+
[
|
|
173
|
+
["moduleId", "grantId"],
|
|
174
|
+
["moduleId", "subjectId", "profileKey", "identityKey"],
|
|
175
|
+
],
|
|
176
|
+
),
|
|
177
|
+
oauthConnectedRevocations: spec(
|
|
178
|
+
{
|
|
179
|
+
jobId: text,
|
|
180
|
+
moduleId: text,
|
|
181
|
+
subjectId: text,
|
|
182
|
+
identityKey: text,
|
|
183
|
+
snapshot: text,
|
|
184
|
+
state: text,
|
|
185
|
+
claimId: optionalText,
|
|
186
|
+
claimedAt: optionalInteger,
|
|
187
|
+
claimExpiresAt: optionalInteger,
|
|
188
|
+
retentionUntil: integer,
|
|
189
|
+
},
|
|
190
|
+
[["jobId"]],
|
|
191
|
+
),
|
|
116
192
|
proofs: spec(
|
|
117
193
|
{
|
|
118
194
|
moduleId: text,
|
|
@@ -2,11 +2,23 @@ import { Context, Effect, Schema } from "effect";
|
|
|
2
2
|
import { SqlClient } from "effect/sql/SqlClient";
|
|
3
3
|
|
|
4
4
|
import { PersistenceConfigurationError } from "./configuration";
|
|
5
|
+
import type { PhysicalTextColumn } from "./native-sql-table";
|
|
5
6
|
import type { StorageTable } from "./storage-tables";
|
|
6
7
|
|
|
7
8
|
const Columns = Schema.Array(Schema.String);
|
|
8
9
|
const Keys = Schema.Array(Columns);
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
const TextColumns = Schema.Array(
|
|
12
|
+
Schema.Struct({ name: Schema.String, type: Schema.String, collation: Schema.String }),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const Metadata = Schema.Struct({
|
|
16
|
+
relation: Schema.String,
|
|
17
|
+
columns: Columns,
|
|
18
|
+
keys: Keys,
|
|
19
|
+
textColumns: TextColumns,
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
const PgMetadata = Schema.Array(Metadata);
|
|
11
23
|
|
|
12
24
|
const SqliteMetadata = Schema.Array(
|
|
@@ -14,10 +26,18 @@ const SqliteMetadata = Schema.Array(
|
|
|
14
26
|
relation: Schema.String,
|
|
15
27
|
columns: Schema.fromJsonString(Columns),
|
|
16
28
|
keys: Schema.fromJsonString(Keys),
|
|
29
|
+
textColumns: Schema.fromJsonString(TextColumns),
|
|
17
30
|
}),
|
|
18
31
|
);
|
|
19
32
|
|
|
20
|
-
const MysqlColumns = Schema.Array(
|
|
33
|
+
const MysqlColumns = Schema.Array(
|
|
34
|
+
Schema.Struct({
|
|
35
|
+
relation: Schema.String,
|
|
36
|
+
name: Schema.String,
|
|
37
|
+
type: Schema.String,
|
|
38
|
+
collation: Schema.NullOr(Schema.String),
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
21
41
|
|
|
22
42
|
const MysqlIndexes = Schema.Array(
|
|
23
43
|
Schema.Struct({
|
|
@@ -101,28 +121,32 @@ export interface StorageValidation {
|
|
|
101
121
|
readonly required: ReadonlyArray<ReadonlyArray<string>>;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
type Dialect = "pg" | "mysql" | "sqlite";
|
|
125
|
+
|
|
126
|
+
/** Share physical type, collation and key observations only during acquisition. */
|
|
127
|
+
const readStorageMetadata = Effect.fnUntraced(
|
|
128
|
+
function* (
|
|
129
|
+
dialect: Dialect,
|
|
130
|
+
physical: ReadonlyArray<Pick<PhysicalStorageTable, "name" | "schema">>,
|
|
131
|
+
) {
|
|
109
132
|
if (
|
|
110
133
|
dialect === "sqlite" &&
|
|
111
|
-
|
|
134
|
+
physical.some((table) => table.schema !== undefined && table.schema !== "main")
|
|
112
135
|
)
|
|
113
136
|
return yield* PersistenceConfigurationError.make({
|
|
114
137
|
reason: "SQLite storage must belong to the main database",
|
|
115
138
|
});
|
|
116
|
-
const
|
|
139
|
+
const captured = yield* SqlClient;
|
|
140
|
+
const client = captured.withoutTransforms();
|
|
117
141
|
const cache = yield* CurrentMetadata;
|
|
118
142
|
|
|
119
143
|
const byRelation = cache?.active
|
|
120
|
-
? (cache.clients.get(
|
|
144
|
+
? (cache.clients.get(captured) ?? new Map<string, typeof Metadata.Type>())
|
|
121
145
|
: new Map<string, typeof Metadata.Type>();
|
|
122
146
|
|
|
123
|
-
if (cache?.active) cache.clients.set(
|
|
147
|
+
if (cache?.active) cache.clients.set(captured, byRelation);
|
|
124
148
|
|
|
125
|
-
const tables = new Map(
|
|
149
|
+
const tables = new Map(physical.map((table) => [qualifiedTableName(table), table]));
|
|
126
150
|
|
|
127
151
|
const missing = [...tables.entries()].filter(
|
|
128
152
|
([relation]) => !byRelation.has(`${dialect}:${relation}`),
|
|
@@ -151,7 +175,11 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
151
175
|
from pragma_index_list(requested.name) i
|
|
152
176
|
where i."unique" = 1 and i.partial = 0
|
|
153
177
|
and not exists(select 1 from pragma_index_info(i.name) where name is null)
|
|
154
|
-
) storage_key where json_array_length(storage_key.columns) > 0) as keys
|
|
178
|
+
) storage_key where json_array_length(storage_key.columns) > 0) as keys,
|
|
179
|
+
(select json_group_array(json_object('name', c.name, 'type', 'TEXT', 'collation', 'BINARY'))
|
|
180
|
+
from pragma_table_info(requested.name) c
|
|
181
|
+
join sqlite_schema s on s.name = requested.name and s.type = 'table'
|
|
182
|
+
where upper(c.type) = 'TEXT' and instr(lower(s.sql), 'collate') = 0) as "textColumns"
|
|
155
183
|
from requested
|
|
156
184
|
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(SqliteMetadata)));
|
|
157
185
|
})
|
|
@@ -168,7 +196,8 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
168
196
|
);
|
|
169
197
|
|
|
170
198
|
const columns = yield* client`
|
|
171
|
-
select requested.relation, c.column_name as name
|
|
199
|
+
select requested.relation, c.column_name as name,
|
|
200
|
+
c.data_type as type, c.collation_name as collation
|
|
172
201
|
from (${requested}) requested join information_schema.columns c
|
|
173
202
|
on c.table_schema = coalesce(requested.schemaName, database()) and c.table_name = requested.tableName
|
|
174
203
|
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(MysqlColumns)));
|
|
@@ -196,6 +225,13 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
196
225
|
columns: columns
|
|
197
226
|
.filter((column) => column.relation === relation)
|
|
198
227
|
.map((column) => column.name),
|
|
228
|
+
textColumns: columns.flatMap((column) =>
|
|
229
|
+
column.relation === relation &&
|
|
230
|
+
column.collation !== null &&
|
|
231
|
+
["varchar", "text", "tinytext", "mediumtext", "longtext"].includes(column.type)
|
|
232
|
+
? [{ name: column.name, type: column.type, collation: column.collation }]
|
|
233
|
+
: [],
|
|
234
|
+
),
|
|
199
235
|
keys: [...grouped.values()]
|
|
200
236
|
.filter((entries) =>
|
|
201
237
|
entries.every((entry) => entry.columnName !== null && entry.prefix === null),
|
|
@@ -229,7 +265,12 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
229
265
|
and i.indpred is null and i.indexprs is null
|
|
230
266
|
and k.ordinality <= i.indnkeyatts
|
|
231
267
|
group by i.indexrelid
|
|
232
|
-
) storage_key), '[]'::jsonb) as keys
|
|
268
|
+
) storage_key), '[]'::jsonb) as keys,
|
|
269
|
+
coalesce((select jsonb_agg(jsonb_build_object(
|
|
270
|
+
'name', a.attname::text, 'type', a.atttypid::text, 'collation', a.attcollation::text))
|
|
271
|
+
from pg_attribute a where a.attrelid = to_regclass(requested.relation)
|
|
272
|
+
and a.attnum > 0 and not a.attisdropped
|
|
273
|
+
and a.atttypid in ('text'::regtype, 'varchar'::regtype)), '[]'::jsonb) as "textColumns"
|
|
233
274
|
from (values ${requested}) requested(relation)
|
|
234
275
|
`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PgMetadata)));
|
|
235
276
|
});
|
|
@@ -237,18 +278,7 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
237
278
|
for (const row of metadata) byRelation.set(`${dialect}:${row.relation}`, row);
|
|
238
279
|
}
|
|
239
280
|
|
|
240
|
-
|
|
241
|
-
const relation = qualifiedTableName(table);
|
|
242
|
-
const actual = byRelation.get(`${dialect}:${relation}`);
|
|
243
|
-
|
|
244
|
-
for (const column of Object.values(table.columns)) {
|
|
245
|
-
if (actual === undefined || !actual.columns.includes(column.name))
|
|
246
|
-
return yield* PersistenceConfigurationError.make({
|
|
247
|
-
reason: `Missing SQL column ${relation}.${column.name}`,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
yield* validateKeys(table, required, actual?.keys ?? []);
|
|
251
|
-
}
|
|
281
|
+
return byRelation;
|
|
252
282
|
},
|
|
253
283
|
Effect.mapError((error) =>
|
|
254
284
|
Schema.is(PersistenceConfigurationError)(error)
|
|
@@ -257,6 +287,76 @@ export const validateStorageBatch = Effect.fnUntraced(
|
|
|
257
287
|
),
|
|
258
288
|
);
|
|
259
289
|
|
|
290
|
+
/** Batch physical metadata reads, reusing only the current acquisition snapshot.
|
|
291
|
+
* Every requirement is still checked; later acquisitions read fresh metadata. */
|
|
292
|
+
export const validateStorageBatch = Effect.fnUntraced(function* (
|
|
293
|
+
dialect: Dialect,
|
|
294
|
+
requirements: ReadonlyArray<StorageValidation>,
|
|
295
|
+
) {
|
|
296
|
+
if (requirements.length === 0) return;
|
|
297
|
+
|
|
298
|
+
const byRelation = yield* readStorageMetadata(
|
|
299
|
+
dialect,
|
|
300
|
+
requirements.map(({ table }) => table),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
for (const { table, required } of requirements) {
|
|
304
|
+
const relation = qualifiedTableName(table);
|
|
305
|
+
const actual = byRelation.get(`${dialect}:${relation}`);
|
|
306
|
+
|
|
307
|
+
for (const column of Object.values(table.columns)) {
|
|
308
|
+
if (actual === undefined || !actual.columns.includes(column.name))
|
|
309
|
+
return yield* PersistenceConfigurationError.make({
|
|
310
|
+
reason: `Missing SQL column ${relation}.${column.name}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
yield* validateKeys(table, required, actual?.keys ?? []);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/** Candidate declarations do not establish SQL equality compatibility. SQLite
|
|
318
|
+
* omits column collations, so only TEXT tables without COLLATE qualify. */
|
|
319
|
+
export const canJoinTextColumns = Effect.fnUntraced(function* (
|
|
320
|
+
columns: ReadonlyArray<PhysicalTextColumn | undefined>,
|
|
321
|
+
) {
|
|
322
|
+
const candidates = columns.filter((column) => column !== undefined);
|
|
323
|
+
|
|
324
|
+
if (candidates.length !== columns.length || candidates.length === 0) return false;
|
|
325
|
+
const client = yield* SqlClient;
|
|
326
|
+
|
|
327
|
+
const dialect = client.onDialectOrElse({
|
|
328
|
+
pg: () => "pg" as const,
|
|
329
|
+
mysql: () => "mysql" as const,
|
|
330
|
+
sqlite: () => "sqlite" as const,
|
|
331
|
+
orElse: () => undefined,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
dialect === undefined ||
|
|
336
|
+
(dialect === "sqlite" &&
|
|
337
|
+
candidates.some(({ table }) => table.schema !== undefined && table.schema !== "main"))
|
|
338
|
+
)
|
|
339
|
+
return false;
|
|
340
|
+
|
|
341
|
+
const metadata = yield* readStorageMetadata(
|
|
342
|
+
dialect,
|
|
343
|
+
candidates.map(({ table }) => table),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const physical = candidates.map(({ table, name }) =>
|
|
347
|
+
metadata
|
|
348
|
+
.get(`${dialect}:${qualifiedTableName(table)}`)
|
|
349
|
+
?.textColumns.find((column) => column.name === name),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const first = physical[0];
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
first !== undefined &&
|
|
356
|
+
physical.every((column) => column?.type === first.type && column.collation === first.collation)
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
260
360
|
/** Validate physical columns and unconditional uniqueness before operations run. */
|
|
261
361
|
export const validateStorage = (
|
|
262
362
|
dialect: "pg" | "mysql" | "sqlite",
|