@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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/AGENTS.md +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +167 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +301 -0
- package/src/mutation/push.saga.ts +225 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +156 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +633 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { DateTime, Duration, RecordId } from "surrealdb";
|
|
2
|
+
import { RecordId as RecordId$1, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
|
|
3
|
+
|
|
4
|
+
//#region src/query/hash.ts
|
|
5
|
+
/** Session-salted: two tabs of one user must not share a remote view. */
|
|
6
|
+
const queryHashInput = (input, sessionId) => JSON.stringify({
|
|
7
|
+
surql: input.surql,
|
|
8
|
+
params: input.params,
|
|
9
|
+
sessionId
|
|
10
|
+
});
|
|
11
|
+
/** Session-independent: survives reload and bucket switch. */
|
|
12
|
+
const viewKeyInput = (input) => JSON.stringify({
|
|
13
|
+
surql: input.surql,
|
|
14
|
+
params: input.params
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/utils/sha256.ts
|
|
19
|
+
/**
|
|
20
|
+
* Lowercase hex SHA-256 of a UTF-8 string, through WebCrypto.
|
|
21
|
+
*
|
|
22
|
+
* Pure and dependency free so it can live in `@spooky-sync/core/pure` and run
|
|
23
|
+
* in a service worker, a dedicated worker or Node (`globalThis.crypto` is
|
|
24
|
+
* there on Node 19+). Query keys are `sha256Hex(JSON.stringify(..))`, see
|
|
25
|
+
* `query/hash.ts`.
|
|
26
|
+
*/
|
|
27
|
+
async function sha256Hex(input) {
|
|
28
|
+
const bytes = new TextEncoder().encode(input);
|
|
29
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
30
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/utils/surql.ts
|
|
35
|
+
const surql = {
|
|
36
|
+
seal(query, options) {
|
|
37
|
+
if (typeof query === "string") return `${query};`;
|
|
38
|
+
const txQuery = query;
|
|
39
|
+
const idx = options?.resultIndex ?? txQuery.statementCount - 1;
|
|
40
|
+
return {
|
|
41
|
+
sql: `${txQuery.sql};`,
|
|
42
|
+
extract(results) {
|
|
43
|
+
return results[idx + 1];
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
tx(queries) {
|
|
48
|
+
return {
|
|
49
|
+
__brand: "TxQuery",
|
|
50
|
+
sql: `BEGIN TRANSACTION;\n${queries.join(";")};\nCOMMIT TRANSACTION`,
|
|
51
|
+
statementCount: queries.length
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
selectById(idVar, returnValues) {
|
|
55
|
+
return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
|
|
56
|
+
},
|
|
57
|
+
selectByFieldsAnd(table, whereVar, returnValues) {
|
|
58
|
+
return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
|
|
59
|
+
},
|
|
60
|
+
create(idVar, dataVar) {
|
|
61
|
+
return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
|
|
62
|
+
},
|
|
63
|
+
createSet(idVar, keyDataVars) {
|
|
64
|
+
return `CREATE ONLY $${idVar} SET ${keyDataVars.map((keyDataVar) => typeof keyDataVar === "string" ? `${keyDataVar} = $${keyDataVar}` : "statement" in keyDataVar ? keyDataVar.statement : `${keyDataVar.key} = $${keyDataVar.variable}`).join(", ")}`;
|
|
65
|
+
},
|
|
66
|
+
upsert(idVar, dataVar) {
|
|
67
|
+
return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
|
|
68
|
+
},
|
|
69
|
+
upsertMerge(idVar, dataVar) {
|
|
70
|
+
return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
|
|
71
|
+
},
|
|
72
|
+
updateMerge(idVar, dataVar) {
|
|
73
|
+
return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
|
|
74
|
+
},
|
|
75
|
+
updateSet(idVar, keyDataVar) {
|
|
76
|
+
return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
|
|
77
|
+
},
|
|
78
|
+
delete(idVar) {
|
|
79
|
+
return `DELETE $${idVar}`;
|
|
80
|
+
},
|
|
81
|
+
let(name, query) {
|
|
82
|
+
return `LET $${name} = (${query})`;
|
|
83
|
+
},
|
|
84
|
+
createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
|
|
85
|
+
switch (t) {
|
|
86
|
+
case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
87
|
+
case "update": {
|
|
88
|
+
let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
|
|
89
|
+
if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
|
|
90
|
+
return stmt;
|
|
91
|
+
}
|
|
92
|
+
case "delete": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'delete', recordId = $${recordIdVar}`;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
returnObject(entries) {
|
|
96
|
+
return `RETURN {${entries.map(({ key, variable }) => `${key}: $${variable}`).join(",")}}`;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/modules/ref-tables.ts
|
|
102
|
+
/**
|
|
103
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
104
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
105
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
106
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
107
|
+
*/
|
|
108
|
+
const ANON_USER_ID = "anon";
|
|
109
|
+
/** The one `_00_list_ref` table every signed-out session shares. */
|
|
110
|
+
const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
|
|
111
|
+
/**
|
|
112
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
113
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
114
|
+
* box.
|
|
115
|
+
*/
|
|
116
|
+
const DEFAULT_REF_MODE = "dedicated";
|
|
117
|
+
/**
|
|
118
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
119
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
120
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
121
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
122
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
123
|
+
*
|
|
124
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
125
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
126
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
127
|
+
*/
|
|
128
|
+
function sanitizeUserId(userId) {
|
|
129
|
+
if (userId === null || userId === void 0) return null;
|
|
130
|
+
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
131
|
+
if (!asString) return null;
|
|
132
|
+
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
133
|
+
if (raw.length === 0) return null;
|
|
134
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
139
|
+
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
140
|
+
* query state, and the mutation outbox never leak across accounts on a shared
|
|
141
|
+
* device. Signed-out sessions share the `anon` bucket.
|
|
142
|
+
*
|
|
143
|
+
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
144
|
+
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
145
|
+
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
146
|
+
*/
|
|
147
|
+
function bucketIdForUser(userId) {
|
|
148
|
+
if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
|
|
149
|
+
const uid = sanitizeUserId(userId);
|
|
150
|
+
if (uid) return uid;
|
|
151
|
+
return `u${cyrb53(String(userId)).toString(16)}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
155
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
156
|
+
* single mode.
|
|
157
|
+
*/
|
|
158
|
+
function listRefTableFor(mode, userId) {
|
|
159
|
+
if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
|
|
160
|
+
if (mode === "single") return "_00_list_ref";
|
|
161
|
+
const uid = sanitizeUserId(userId);
|
|
162
|
+
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/query/sql.ts
|
|
167
|
+
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
168
|
+
const VIEW_TABLE = "_00_view";
|
|
169
|
+
const LEGACY_VIEW_TABLE = "_00_window";
|
|
170
|
+
const viewRecordId = (viewKey) => new RecordId(VIEW_TABLE, viewKey);
|
|
171
|
+
/**
|
|
172
|
+
* The durable `_00_view` row: the confirmed membership plus the subquery
|
|
173
|
+
* children, which the GC must keep and a cold scan may paint as much as the
|
|
174
|
+
* members.
|
|
175
|
+
*/
|
|
176
|
+
const viewRow = (ids, children, confirmed, now) => ({
|
|
177
|
+
ids,
|
|
178
|
+
children,
|
|
179
|
+
confirmed,
|
|
180
|
+
updatedAt: now
|
|
181
|
+
});
|
|
182
|
+
/** Every `_00_view` row: the durable view index and the GC read it. */
|
|
183
|
+
const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
|
|
184
|
+
/** Copy every legacy `_00_window` row into `_00_view` once. */
|
|
185
|
+
const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
186
|
+
const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
187
|
+
const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
|
|
188
|
+
const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
|
|
189
|
+
const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
|
|
190
|
+
const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
|
|
191
|
+
const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
|
|
192
|
+
/** The single-query read: edges, meta, subquery children in one request. */
|
|
193
|
+
const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
|
|
194
|
+
/** The many-query read: all edges + all metas in one request. */
|
|
195
|
+
const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
196
|
+
/**
|
|
197
|
+
* The session's one LIVE subscription on its `_00_list_ref` table.
|
|
198
|
+
*
|
|
199
|
+
* `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
|
|
200
|
+
* notification so the body arrives with the doorbell.
|
|
201
|
+
*
|
|
202
|
+
* On the anon table it is filtered to this client's own edges. Every
|
|
203
|
+
* signed-out session shares that table, so unfiltered each one received (and
|
|
204
|
+
* the server fetched a body for) every anonymous edge change of every other
|
|
205
|
+
* session, and the client then dropped all but its own. An edge carries the
|
|
206
|
+
* `session::id()` of whichever socket last registered its view, which is a
|
|
207
|
+
* socket of this client but not necessarily the current one: a view stays
|
|
208
|
+
* registered across a reconnect, and a reload keeps its salt and its views,
|
|
209
|
+
* so their older edges still name an earlier socket. `sessionIds` are those
|
|
210
|
+
* earlier sockets. The current one is matched by `session::id()` itself,
|
|
211
|
+
* which SurrealDB evaluates in the subscriber's own session, so the filter
|
|
212
|
+
* holds before the client has learned its new id.
|
|
213
|
+
*
|
|
214
|
+
* The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
|
|
215
|
+
* and without it nothing matches. Per-user tables need no filter.
|
|
216
|
+
*/
|
|
217
|
+
function listRefLive(table, opts) {
|
|
218
|
+
const join = opts.inlineBodies ? " FETCH out" : "";
|
|
219
|
+
if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
|
|
220
|
+
return {
|
|
221
|
+
sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
|
|
222
|
+
vars: { sids: [...opts.sessionIds] }
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Register + read back edges/meta/children in ONE request. */
|
|
226
|
+
const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
|
|
227
|
+
const registerVars = (payload) => ({
|
|
228
|
+
config: payload,
|
|
229
|
+
in: payload.id
|
|
230
|
+
});
|
|
231
|
+
/** One heartbeat statement per view id; result index i answers id i. */
|
|
232
|
+
function heartbeatBatch(ids) {
|
|
233
|
+
const vars = {};
|
|
234
|
+
return {
|
|
235
|
+
sql: ids.map((id, i) => {
|
|
236
|
+
vars[`id${i}`] = id;
|
|
237
|
+
return `fn::query::heartbeat($id${i})`;
|
|
238
|
+
}).join(";\n"),
|
|
239
|
+
vars
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
243
|
+
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
244
|
+
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
245
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
246
|
+
/**
|
|
247
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
248
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
249
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
250
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
251
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
252
|
+
*/
|
|
253
|
+
function leaveViewsBeacon(ids, sessionIds) {
|
|
254
|
+
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
255
|
+
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
256
|
+
if (list.length === 0 || sids.length === 0) return null;
|
|
257
|
+
const sidList = sids.map((sid) => `'${sid}'`).join(", ");
|
|
258
|
+
return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
259
|
+
}
|
|
260
|
+
const bodySelect = () => "SELECT * FROM $ids";
|
|
261
|
+
/** Local transaction that MERGEs many fetched bodies. */
|
|
262
|
+
function upsertBodiesTx(records) {
|
|
263
|
+
const vars = {};
|
|
264
|
+
const stmts = records.map((r, i) => {
|
|
265
|
+
vars[`id${i}`] = r.id;
|
|
266
|
+
vars[`content${i}`] = r.content;
|
|
267
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
query: surql.seal(surql.tx(stmts)),
|
|
271
|
+
vars
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/utils/parser.ts
|
|
277
|
+
/**
|
|
278
|
+
* Parse a QUERY's params. Unlike a record's fields, a param name is not always a
|
|
279
|
+
* column: an `_or` branch binds under a synthetic `white__or0` (see
|
|
280
|
+
* `orParamName` in @spooky-sync/query-builder), and the surql that references it
|
|
281
|
+
* is already written. So resolve the column through the synthetic name, and keep
|
|
282
|
+
* a param we cannot type rather than dropping it.
|
|
283
|
+
*
|
|
284
|
+
* Dropping was the bug: `parseParams` kept only column-named params, so every
|
|
285
|
+
* `_or` query registered with `$or0`/`$or1` unbound and matched no rows at all,
|
|
286
|
+
* silently (rowCount 0, errorCount 0).
|
|
287
|
+
*/
|
|
288
|
+
function parseQueryParams(tableSchema, params) {
|
|
289
|
+
const parsedParams = {};
|
|
290
|
+
for (const [key, value] of Object.entries(params)) {
|
|
291
|
+
if (value === void 0) continue;
|
|
292
|
+
const column = tableSchema[key] ?? tableSchema[baseFieldOfParam(key)];
|
|
293
|
+
parsedParams[key] = column ? parseValue(key, column, value) : value;
|
|
294
|
+
}
|
|
295
|
+
return parsedParams;
|
|
296
|
+
}
|
|
297
|
+
function parseValue(name, column, value) {
|
|
298
|
+
if (column.recordId) {
|
|
299
|
+
if (value instanceof RecordId$1) return value;
|
|
300
|
+
if (typeof value === "string") return parseRecordIdString(value);
|
|
301
|
+
throw new Error(`Invalid value for ${name}: ${value}`);
|
|
302
|
+
}
|
|
303
|
+
if (column.dateTime) {
|
|
304
|
+
if (value instanceof Date) return value;
|
|
305
|
+
if (value instanceof DateTime) return value.toDate();
|
|
306
|
+
if (typeof value === "number" || typeof value === "string") return new Date(value);
|
|
307
|
+
throw new Error(`Invalid value for ${name}: ${value}`);
|
|
308
|
+
}
|
|
309
|
+
return value;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/utils/index.ts
|
|
314
|
+
const encodeRecordId = (recordId) => {
|
|
315
|
+
return `${recordId.table.name}:${recordId.id}`;
|
|
316
|
+
};
|
|
317
|
+
const parseRecordIdString = (id) => {
|
|
318
|
+
const [table, ...idParts] = id.split(":");
|
|
319
|
+
return new RecordId(table, idParts.join(":"));
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* 32 random bytes, hex: the secret a page proves it owns its anonymous views
|
|
323
|
+
* with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
|
|
324
|
+
* than `randomUUID`, which a page served over plain http does not have.
|
|
325
|
+
*/
|
|
326
|
+
function mintOwnerSecret() {
|
|
327
|
+
const bytes = new Uint8Array(32);
|
|
328
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
329
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
333
|
+
* releases expose it under `milliseconds` (current) or the older private
|
|
334
|
+
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
335
|
+
*/
|
|
336
|
+
function durationMillis(duration) {
|
|
337
|
+
const d = duration;
|
|
338
|
+
return Number(d.milliseconds || d._milliseconds || 0);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Parse duration string or Duration object to milliseconds
|
|
342
|
+
*/
|
|
343
|
+
function parseDuration(duration) {
|
|
344
|
+
if (duration instanceof Duration) {
|
|
345
|
+
const ms = durationMillis(duration);
|
|
346
|
+
if (ms) return ms;
|
|
347
|
+
const str = duration.toString();
|
|
348
|
+
if (str !== "[object Object]") return parseDuration(str);
|
|
349
|
+
return 6e5;
|
|
350
|
+
}
|
|
351
|
+
if (typeof duration === "bigint") return Number(duration);
|
|
352
|
+
if (typeof duration !== "string") return 6e5;
|
|
353
|
+
const match = duration.match(/^(\d+)([smh])$/);
|
|
354
|
+
if (!match) return 6e5;
|
|
355
|
+
const val = Number.parseInt(match[1], 10);
|
|
356
|
+
switch (match[2]) {
|
|
357
|
+
case "s": return val * 1e3;
|
|
358
|
+
case "h": return val * 36e5;
|
|
359
|
+
default: return val * 6e4;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Reject after `timeoutMs` if `promise` hasn't settled.
|
|
364
|
+
*
|
|
365
|
+
* Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
|
|
366
|
+
* parks each call in a pending map and only rejects it when the socket reports
|
|
367
|
+
* a close. On a half-open socket (peer gone, no `close` event, `readyState`
|
|
368
|
+
* still OPEN) the call never settles at all, which wedges anything serialized
|
|
369
|
+
* behind it.
|
|
370
|
+
*
|
|
371
|
+
* `message` MUST contain "timed out" so `classifySyncError` treats the
|
|
372
|
+
* rejection as `network` — that's what makes the sync queues retry the
|
|
373
|
+
* operation instead of rolling the mutation back as an application error.
|
|
374
|
+
*
|
|
375
|
+
* Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
|
|
376
|
+
* is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
|
|
377
|
+
* settlement is absorbed, so attach no expectations to it after a timeout.
|
|
378
|
+
*/
|
|
379
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
380
|
+
if (!(timeoutMs > 0)) return promise;
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
const timer = setTimeout(() => reject(typeof message === "function" ? message() : new Error(message)), timeoutMs);
|
|
383
|
+
promise.then((value) => {
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
resolve(value);
|
|
386
|
+
}, (err) => {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
reject(err);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/query/membership.ts
|
|
395
|
+
function metaFromRow(row) {
|
|
396
|
+
if (!row || typeof row !== "object") return {
|
|
397
|
+
present: false,
|
|
398
|
+
rowCount: null,
|
|
399
|
+
state: null
|
|
400
|
+
};
|
|
401
|
+
const state = row.state === "materializing" || row.state === "ready" ? row.state : null;
|
|
402
|
+
return {
|
|
403
|
+
present: true,
|
|
404
|
+
rowCount: typeof row.rowCount === "number" ? row.rowCount : null,
|
|
405
|
+
state
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
|
|
409
|
+
function dedupeRecordVersions(pairs) {
|
|
410
|
+
if (pairs.length < 2) return pairs;
|
|
411
|
+
const best = /* @__PURE__ */ new Map();
|
|
412
|
+
for (const [id, version] of pairs) {
|
|
413
|
+
const prev = best.get(id);
|
|
414
|
+
if (prev === void 0 || version > prev) best.set(id, version);
|
|
415
|
+
}
|
|
416
|
+
if (best.size === pairs.length) return pairs;
|
|
417
|
+
return Array.from(best.entries());
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A statement that did not answer (an ERR, or no result at all). Kept apart
|
|
421
|
+
* from a `null` result, which is the server saying "no such row": reading a
|
|
422
|
+
* failed statement as a missing row turned every timeout under load into a
|
|
423
|
+
* lost view and re-registered it, over and over (whitepawn, 2026-09-16).
|
|
424
|
+
*/
|
|
425
|
+
const FAILED = Symbol("statement failed");
|
|
426
|
+
const toPairs = (rows) => dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
|
|
427
|
+
/**
|
|
428
|
+
* Fold the single-query statement batch (edges, meta, children) into a
|
|
429
|
+
* snapshot. `null` when any of the three did not answer: half an answer is
|
|
430
|
+
* not one, and a missing meta would read as a lost view.
|
|
431
|
+
*/
|
|
432
|
+
function snapshotFromSingle(items, metaRow, children) {
|
|
433
|
+
if (!Array.isArray(items) || metaRow === FAILED || children === FAILED) return null;
|
|
434
|
+
return {
|
|
435
|
+
primary: toPairs(items),
|
|
436
|
+
subquery: toPairs(children),
|
|
437
|
+
meta: metaFromRow(metaRow)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/utils/edges.ts
|
|
443
|
+
/**
|
|
444
|
+
* Reading `_00_list_ref` edges as a LIVE notification delivers them. Pure, so
|
|
445
|
+
* both the full client and the wasm-free live feed (`@spooky-sync/core/live`)
|
|
446
|
+
* decode the same wire shape the same way.
|
|
447
|
+
*/
|
|
448
|
+
/** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
|
|
449
|
+
function hashOfEdge(value) {
|
|
450
|
+
const inId = value?.in;
|
|
451
|
+
if (!inId) return null;
|
|
452
|
+
const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
|
|
453
|
+
return str.length > 0 ? str : null;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The row an edge notification carries, when the subscription joined it on.
|
|
457
|
+
*
|
|
458
|
+
* Without `FETCH out` the edge's `out` is a record id and there is nothing to
|
|
459
|
+
* land, so this returns null and the caller falls back to fetching the body.
|
|
460
|
+
* It also returns null for a row the session may not read (the join yields
|
|
461
|
+
* `out: null`) and for an edge whose target has been deleted.
|
|
462
|
+
*/
|
|
463
|
+
function rowOfEdge(value) {
|
|
464
|
+
const edge = value;
|
|
465
|
+
const out = edge?.out;
|
|
466
|
+
if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId) return null;
|
|
467
|
+
const rid = out.id;
|
|
468
|
+
if (!(rid instanceof RecordId)) return null;
|
|
469
|
+
const id = encodeRecordId(rid);
|
|
470
|
+
const version = typeof edge?.version === "number" ? edge.version : null;
|
|
471
|
+
if (version === null) return null;
|
|
472
|
+
return {
|
|
473
|
+
id,
|
|
474
|
+
version,
|
|
475
|
+
record: out
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
//#endregion
|
|
480
|
+
export { singleSnapshotSelect as A, sanitizeUserId as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, ANON_LIST_REF_TABLE as F, sha256Hex as H, ANON_USER_ID as I, DEFAULT_REF_MODE as L, upsertBodiesTx as M, viewRecordId as N, registerSelect as O, viewRow as P, bucketIdForUser as R, listRefLive as S, queryRowCountSelect as T, queryHashInput as U, surql as V, viewKeyInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, subqueryListRefSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, listRefTableFor as z };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region src/modules/auth/impersonation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Admin impersonation, client side.
|
|
4
|
+
*
|
|
5
|
+
* The server does the enforcing (see `impersonation_remote.surql` in the CLI):
|
|
6
|
+
* an admin calls `fn::_00_impersonate::start`, receives a token for the
|
|
7
|
+
* `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
|
|
8
|
+
* the target user. Everything here is state and timers around that:
|
|
9
|
+
*
|
|
10
|
+
* - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
|
|
11
|
+
* reload in between) can return to it;
|
|
12
|
+
* - the impersonation state is derived from the token's claims, never stored
|
|
13
|
+
* separately, so it cannot disagree with the session actually in use;
|
|
14
|
+
* - the token is re-signed before it expires, and the session is polled so a
|
|
15
|
+
* stop or revocation made elsewhere reaches an open WebSocket quickly.
|
|
16
|
+
*/
|
|
17
|
+
/** Access method impersonation tokens are issued for. */
|
|
18
|
+
const IMPERSONATION_ACCESS = "_00_impersonate";
|
|
19
|
+
/**
|
|
20
|
+
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
21
|
+
* server still enforces the token on every request; this is only so the client
|
|
22
|
+
* can act on what it already holds before a round trip completes.
|
|
23
|
+
*
|
|
24
|
+
* Returns nulls on any malformed input.
|
|
25
|
+
*/
|
|
26
|
+
function decodeTokenClaims(token) {
|
|
27
|
+
const none = {
|
|
28
|
+
access: null,
|
|
29
|
+
userId: null,
|
|
30
|
+
exp: null,
|
|
31
|
+
impersonation: null
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const payload = token.split(".")[1];
|
|
35
|
+
if (!payload) return none;
|
|
36
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
38
|
+
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
39
|
+
const claims = JSON.parse(json);
|
|
40
|
+
const ac = claims.AC ?? claims.ac;
|
|
41
|
+
const id = claims.ID ?? claims.id;
|
|
42
|
+
const access = typeof ac === "string" ? ac : null;
|
|
43
|
+
const session = claims.spky_imp;
|
|
44
|
+
const admin = claims.spky_admin;
|
|
45
|
+
return {
|
|
46
|
+
access,
|
|
47
|
+
userId: typeof id === "string" ? id : null,
|
|
48
|
+
exp: typeof claims.exp === "number" ? claims.exp : null,
|
|
49
|
+
impersonation: access === IMPERSONATION_ACCESS && typeof session === "string" && typeof admin === "string" ? {
|
|
50
|
+
session,
|
|
51
|
+
admin
|
|
52
|
+
} : null
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
export { decodeTokenClaims as n, IMPERSONATION_ACCESS as t };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//#region src/modules/ref-tables.d.ts
|
|
2
|
+
type RefMode = 'single' | 'dedicated';
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
5
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
6
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
7
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
8
|
+
*/
|
|
9
|
+
declare const ANON_USER_ID = "anon";
|
|
10
|
+
/** The one `_00_list_ref` table every signed-out session shares. */
|
|
11
|
+
declare const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
|
|
12
|
+
/**
|
|
13
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
14
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
15
|
+
* box.
|
|
16
|
+
*/
|
|
17
|
+
declare const DEFAULT_REF_MODE: RefMode;
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
20
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
21
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
22
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
23
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
24
|
+
*
|
|
25
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
26
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
27
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
28
|
+
*/
|
|
29
|
+
declare function sanitizeUserId(userId: unknown): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
32
|
+
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
33
|
+
* query state, and the mutation outbox never leak across accounts on a shared
|
|
34
|
+
* device. Signed-out sessions share the `anon` bucket.
|
|
35
|
+
*
|
|
36
|
+
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
37
|
+
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
38
|
+
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
39
|
+
*/
|
|
40
|
+
declare function bucketIdForUser(userId: unknown): string;
|
|
41
|
+
/**
|
|
42
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
43
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
44
|
+
* single mode.
|
|
45
|
+
*/
|
|
46
|
+
declare function listRefTableFor(mode: RefMode, userId: unknown): string;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { bucketIdForUser as a, RefMode as i, ANON_USER_ID as n, listRefTableFor as o, DEFAULT_REF_MODE as r, sanitizeUserId as s, ANON_LIST_REF_TABLE as t };
|