@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
|
@@ -1,17 +1,57 @@
|
|
|
1
|
-
import { RemoteDatabaseService } from '../../services/database/remote';
|
|
2
|
-
import {
|
|
3
|
-
import { DataModule } from '../data/index';
|
|
4
|
-
import {
|
|
1
|
+
import type { RemoteDatabaseService } from '../../services/database/remote';
|
|
2
|
+
import type {
|
|
5
3
|
SchemaStructure,
|
|
6
4
|
AccessDefinition,
|
|
7
5
|
ColumnSchema,
|
|
8
6
|
TypeNameToTypeMap,
|
|
9
7
|
} from '@spooky-sync/query-builder';
|
|
10
|
-
import { Logger } from '../../services/logger/index';
|
|
11
|
-
import { encodeRecordId } from '../../utils/index';
|
|
8
|
+
import type { Logger } from '../../services/logger/index';
|
|
12
9
|
export * from './events/index';
|
|
13
10
|
import { AuthEventTypes, createAuthEventSystem } from './events/index';
|
|
14
|
-
import { PersistenceClient } from '../../types';
|
|
11
|
+
import type { PersistenceClient } from '../../types';
|
|
12
|
+
import { classifySyncError } from '../../utils/error-classification';
|
|
13
|
+
import { encodeRecordId } from '../../utils/index';
|
|
14
|
+
import { RecordId } from 'surrealdb';
|
|
15
|
+
import {
|
|
16
|
+
decodeTokenClaims,
|
|
17
|
+
impersonationFromToken,
|
|
18
|
+
sameImpersonation,
|
|
19
|
+
ImpersonationTimers,
|
|
20
|
+
ORIGIN_TOKEN_KEY,
|
|
21
|
+
} from './impersonation';
|
|
22
|
+
import type { ImpersonationInfo } from './impersonation';
|
|
23
|
+
export * from './impersonation';
|
|
24
|
+
|
|
25
|
+
/** What `fn::_00_impersonate::list_users` returns per row. */
|
|
26
|
+
export interface ImpersonationCandidate {
|
|
27
|
+
id: string;
|
|
28
|
+
is_admin: boolean;
|
|
29
|
+
[field: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An open session, as `fn::_00_impersonate::active` lists it. */
|
|
33
|
+
export interface ActiveImpersonation {
|
|
34
|
+
session: string;
|
|
35
|
+
target: string;
|
|
36
|
+
admin: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
started_at: unknown;
|
|
39
|
+
expires_at: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a sign-out hook sees: the session that is about to end. */
|
|
43
|
+
export interface SignOutContext {
|
|
44
|
+
/** `table:id` of the signed-in record, or null. */
|
|
45
|
+
userId: string | null;
|
|
46
|
+
token: string | null;
|
|
47
|
+
/** The session is an admin impersonation (its token is the target's). */
|
|
48
|
+
impersonating: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
|
|
52
|
+
|
|
53
|
+
/** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
|
|
54
|
+
export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
|
|
15
55
|
|
|
16
56
|
// Helper to pretty print types
|
|
17
57
|
type Prettify<T> = {
|
|
@@ -38,14 +78,55 @@ type ExtractAccessParams<
|
|
|
38
78
|
}>
|
|
39
79
|
: never;
|
|
40
80
|
|
|
81
|
+
function decodeAccessFromToken(token: string): string | null {
|
|
82
|
+
return decodeTokenClaims(token).access;
|
|
83
|
+
}
|
|
84
|
+
|
|
41
85
|
export class AuthService<S extends SchemaStructure> {
|
|
42
86
|
// State
|
|
43
87
|
public token: string | null = null;
|
|
44
88
|
public currentUser: any | null = null;
|
|
45
89
|
public isAuthenticated: boolean = false;
|
|
90
|
+
/**
|
|
91
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
92
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
93
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
94
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
95
|
+
*/
|
|
96
|
+
public access: string | null = null;
|
|
46
97
|
public isLoading: boolean = true;
|
|
47
98
|
|
|
48
99
|
private events = createAuthEventSystem();
|
|
100
|
+
private signOutHooks = new Set<SignOutHook>();
|
|
101
|
+
|
|
102
|
+
/** The impersonation last announced, for change detection. */
|
|
103
|
+
private announcedImpersonation: ImpersonationInfo | null = null;
|
|
104
|
+
/** Target of an impersonation that just ended, until the client purges its
|
|
105
|
+
* local bucket (see `consumeEndedImpersonation`). */
|
|
106
|
+
private endedImpersonationTarget: string | null = null;
|
|
107
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
108
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
109
|
+
* they share this one: a second stop running beside the first found the
|
|
110
|
+
* admin token already taken and signed the admin out. */
|
|
111
|
+
private stopping: Promise<void> | null = null;
|
|
112
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
113
|
+
private renewing: Promise<void> | null = null;
|
|
114
|
+
private impersonationTimers = new ImpersonationTimers({
|
|
115
|
+
current: () => this.impersonation,
|
|
116
|
+
renew: () => this.renewImpersonation(),
|
|
117
|
+
stillActive: async () => {
|
|
118
|
+
const [row] = await this.remote.query<[unknown]>('RETURN fn::_00_impersonate::current()');
|
|
119
|
+
return row !== null && row !== undefined;
|
|
120
|
+
},
|
|
121
|
+
endedRemotely: async (reason) => {
|
|
122
|
+
this.logger.warn(
|
|
123
|
+
{ reason, Category: 'sp00ky-client::AuthService::impersonation' },
|
|
124
|
+
'Impersonation ended on the server; returning to the admin session'
|
|
125
|
+
);
|
|
126
|
+
await this.stopImpersonating({ serverEnded: true });
|
|
127
|
+
},
|
|
128
|
+
isNetworkError: (error) => classifySyncError(error) === 'network',
|
|
129
|
+
});
|
|
49
130
|
|
|
50
131
|
public get eventSystem() {
|
|
51
132
|
return this.events;
|
|
@@ -69,10 +150,10 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
69
150
|
/**
|
|
70
151
|
* Subscribe to auth state changes.
|
|
71
152
|
* callback is called immediately with current value and whenever validation status changes.
|
|
153
|
+
* `immediate: false` skips that first call: only changes are reported.
|
|
72
154
|
*/
|
|
73
|
-
subscribe(cb: (userId: string | null) => void): () => void {
|
|
74
|
-
|
|
75
|
-
cb(this.currentUser?.id || null);
|
|
155
|
+
subscribe(cb: (userId: string | null) => void, opts: { immediate?: boolean } = {}): () => void {
|
|
156
|
+
if (opts.immediate !== false) cb(this.announcedUserId());
|
|
76
157
|
|
|
77
158
|
const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
|
|
78
159
|
cb(event.payload);
|
|
@@ -83,9 +164,283 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
83
164
|
};
|
|
84
165
|
}
|
|
85
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Run `hook` at the start of every sign-out, while the token and the socket
|
|
169
|
+
* still belong to the leaving user (Web Push removes this device's
|
|
170
|
+
* subscription here). Hooks are best-effort: they run in parallel, errors
|
|
171
|
+
* are swallowed, and the sign-out goes on after
|
|
172
|
+
* {@link SIGN_OUT_HOOK_TIMEOUT_MS} whatever they are doing. Returns a disposer.
|
|
173
|
+
*/
|
|
174
|
+
onBeforeSignOut(hook: SignOutHook): () => void {
|
|
175
|
+
this.signOutHooks.add(hook);
|
|
176
|
+
return () => {
|
|
177
|
+
this.signOutHooks.delete(hook);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async runSignOutHooks(): Promise<void> {
|
|
182
|
+
if (this.signOutHooks.size === 0 || !this.isAuthenticated) return;
|
|
183
|
+
const id = this.currentUser?.id;
|
|
184
|
+
const ctx: SignOutContext = {
|
|
185
|
+
userId: id ? (typeof id === 'string' ? id : encodeRecordId(id)) : null,
|
|
186
|
+
token: this.token,
|
|
187
|
+
impersonating: this.impersonation !== null,
|
|
188
|
+
};
|
|
189
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
190
|
+
const deadline = new Promise<void>((resolve) => {
|
|
191
|
+
timer = setTimeout(resolve, SIGN_OUT_HOOK_TIMEOUT_MS);
|
|
192
|
+
});
|
|
193
|
+
const hooks = Promise.allSettled([...this.signOutHooks].map(async (hook) => hook(ctx)));
|
|
194
|
+
try {
|
|
195
|
+
await Promise.race([hooks, deadline]);
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The signed-in user as subscribers see it: the raw `table:id` string, the
|
|
203
|
+
* way the token's `ID` claim spells it. `currentUser.id` is that string while
|
|
204
|
+
* the session is restored from the token and the server's `RecordId` once
|
|
205
|
+
* `check()` has answered, and the two must announce as the same user: every
|
|
206
|
+
* subscriber that compares ids (feature flags, app release) took the switch
|
|
207
|
+
* for a new principal and reset, so flag-gated UI blinked off and back on a
|
|
208
|
+
* few seconds into every reload. A `RecordId`'s own `toString()` will not do
|
|
209
|
+
* either, as it escapes ids like `user:⟨5abc⟩` that the claim spells raw.
|
|
210
|
+
*/
|
|
211
|
+
private announcedUserId(): string | null {
|
|
212
|
+
const id = this.currentUser?.id;
|
|
213
|
+
if (!id) return null;
|
|
214
|
+
if (typeof id === 'string') return id;
|
|
215
|
+
return id instanceof RecordId ? encodeRecordId(id as RecordId<string>) : String(id);
|
|
216
|
+
}
|
|
217
|
+
|
|
86
218
|
private notifyListeners() {
|
|
87
|
-
const userId = this.
|
|
219
|
+
const userId = this.announcedUserId();
|
|
88
220
|
this.events.emit(AuthEventTypes.AuthStateChanged, userId);
|
|
221
|
+
const impersonation = this.impersonation;
|
|
222
|
+
if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
|
|
223
|
+
this.announcedImpersonation = impersonation;
|
|
224
|
+
this.events.emit(AuthEventTypes.ImpersonationChanged, impersonation);
|
|
225
|
+
}
|
|
226
|
+
this.impersonationTimers.sync();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The impersonation the current session is, or null. Derived from the
|
|
231
|
+
* token in use, so it always describes the identity the server sees.
|
|
232
|
+
*/
|
|
233
|
+
get impersonation(): ImpersonationInfo | null {
|
|
234
|
+
return this.isAuthenticated ? impersonationFromToken(this.token) : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Subscribe to impersonation changes; called immediately with the current value. */
|
|
238
|
+
subscribeImpersonation(cb: (info: ImpersonationInfo | null) => void): () => void {
|
|
239
|
+
cb(this.impersonation);
|
|
240
|
+
const id = this.events.subscribe(AuthEventTypes.ImpersonationChanged, (event) => {
|
|
241
|
+
cb(event.payload);
|
|
242
|
+
});
|
|
243
|
+
return () => {
|
|
244
|
+
this.events.unsubscribe(id);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Act as `targetId` (a record id such as `user:abc`). Admins only, and only
|
|
250
|
+
* when the project enabled `impersonation` in sp00ky.yml: the server refuses
|
|
251
|
+
* everything else. `reason` is recorded in the audit log.
|
|
252
|
+
*
|
|
253
|
+
* The admin's session is kept aside and restored by
|
|
254
|
+
* {@link stopImpersonating}. Local data switches to the target's bucket
|
|
255
|
+
* exactly as on a sign-in, and that bucket is removed on stop.
|
|
256
|
+
*/
|
|
257
|
+
async impersonate(targetId: string, reason: string): Promise<ImpersonationInfo> {
|
|
258
|
+
if (this.impersonation) throw new Error('Already impersonating; stop first.');
|
|
259
|
+
if (!this.isAuthenticated || !this.token) throw new Error('Sign in as an admin first.');
|
|
260
|
+
const originToken = this.token;
|
|
261
|
+
const [started] = await this.remote.query<[{ token: string }]>(
|
|
262
|
+
'RETURN fn::_00_impersonate::start($target, $reason)',
|
|
263
|
+
{ target: targetId, reason }
|
|
264
|
+
);
|
|
265
|
+
if (!started?.token) throw new Error('The server did not return an impersonation token.');
|
|
266
|
+
|
|
267
|
+
await this.persistenceClient.set(ORIGIN_TOKEN_KEY, originToken);
|
|
268
|
+
try {
|
|
269
|
+
await this.remote.getClient().authenticate(started.token);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// The server refused the session it just issued (for example the
|
|
272
|
+
// target is an admin, or no longer exists). Nothing changed locally
|
|
273
|
+
// yet; put the admin's token back on the transport.
|
|
274
|
+
await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
|
|
275
|
+
this.remote.setAuthToken(originToken);
|
|
276
|
+
await this.remote.getClient().authenticate(originToken).catch(() => undefined);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
await this.check(started.token);
|
|
280
|
+
// A method call, not the getter: TS narrowed the getter to null above.
|
|
281
|
+
const info = impersonationFromToken(this.isAuthenticated ? this.token : null);
|
|
282
|
+
if (!info) {
|
|
283
|
+
await this.stopImpersonating();
|
|
284
|
+
throw new Error('Impersonation could not be established.');
|
|
285
|
+
}
|
|
286
|
+
this.logger.info(
|
|
287
|
+
{ target: info.target, session: info.session, Category: 'sp00ky-client::AuthService::impersonate' },
|
|
288
|
+
'Impersonation started'
|
|
289
|
+
);
|
|
290
|
+
return info;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Return to the admin session. Always leaves the impersonated identity,
|
|
295
|
+
* even when the server cannot be reached: the admin token is restored
|
|
296
|
+
* locally first and verified afterwards, and a rejected admin token ends in
|
|
297
|
+
* a full sign-out rather than staying on the target.
|
|
298
|
+
*/
|
|
299
|
+
async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
|
|
300
|
+
if (this.stopping) return this.stopping;
|
|
301
|
+
if (!this.impersonation) return;
|
|
302
|
+
this.stopping = this.leaveImpersonation(opts).finally(() => {
|
|
303
|
+
this.stopping = null;
|
|
304
|
+
});
|
|
305
|
+
return this.stopping;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async leaveImpersonation(opts: { serverEnded?: boolean }): Promise<void> {
|
|
309
|
+
this.impersonationTimers.clear();
|
|
310
|
+
// A renewal already sent finishes first, so it cannot put the target's
|
|
311
|
+
// token back on the socket after the admin's (it sees `stopping` and
|
|
312
|
+
// stands down).
|
|
313
|
+
await this.renewing?.catch(() => undefined);
|
|
314
|
+
const info = this.impersonation;
|
|
315
|
+
if (!info) return;
|
|
316
|
+
if (!opts.serverEnded) {
|
|
317
|
+
try {
|
|
318
|
+
await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// The token expires on its own; the local switch below is what matters.
|
|
321
|
+
this.logger.warn(
|
|
322
|
+
{ error, Category: 'sp00ky-client::AuthService::stopImpersonating' },
|
|
323
|
+
'Could not end the impersonation session on the server'
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
this.endedImpersonationTarget = info.target;
|
|
328
|
+
const origin = await this.persistenceClient.get<string>(ORIGIN_TOKEN_KEY);
|
|
329
|
+
await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
|
|
330
|
+
if (!origin || impersonationFromToken(origin)) {
|
|
331
|
+
await this.signOut();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const { access, userId } = decodeTokenClaims(origin);
|
|
335
|
+
if (!userId) {
|
|
336
|
+
await this.signOut();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Optimistic switch back, as a warm boot does, then verify.
|
|
340
|
+
this.token = origin;
|
|
341
|
+
this.remote.setAuthToken(origin);
|
|
342
|
+
this.currentUser = { id: userId };
|
|
343
|
+
this.access = access ?? this.defaultAccessName();
|
|
344
|
+
await this.persistenceClient.set('sp00ky_auth_token', origin);
|
|
345
|
+
this.notifyListeners();
|
|
346
|
+
await this.check(origin);
|
|
347
|
+
this.logger.info(
|
|
348
|
+
{ target: info.target, Category: 'sp00ky-client::AuthService::stopImpersonating' },
|
|
349
|
+
'Impersonation stopped'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Re-sign the impersonation token before it expires. */
|
|
354
|
+
async renewImpersonation(): Promise<void> {
|
|
355
|
+
if (!this.impersonation || this.stopping) return;
|
|
356
|
+
const renewal = this.applyRenewal();
|
|
357
|
+
this.renewing = renewal;
|
|
358
|
+
try {
|
|
359
|
+
await renewal;
|
|
360
|
+
} finally {
|
|
361
|
+
if (this.renewing === renewal) this.renewing = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async applyRenewal(): Promise<void> {
|
|
366
|
+
const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
|
|
367
|
+
if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
|
|
368
|
+
// A stop began while the server was signing: the session is being left,
|
|
369
|
+
// so a fresh token for it must not go on the socket or into storage.
|
|
370
|
+
if (this.stopping || !this.impersonation) return;
|
|
371
|
+
this.remote.setAuthToken(renewed.token);
|
|
372
|
+
await this.remote.getClient().authenticate(renewed.token);
|
|
373
|
+
this.token = renewed.token;
|
|
374
|
+
await this.persistenceClient.set('sp00ky_auth_token', renewed.token);
|
|
375
|
+
this.notifyListeners();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Users an admin may pick from. Admin-only on the server. */
|
|
379
|
+
async searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]> {
|
|
380
|
+
const [rows] = await this.remote.query<[ImpersonationCandidate[]]>(
|
|
381
|
+
'RETURN fn::_00_impersonate::list_users($search)',
|
|
382
|
+
{ search }
|
|
383
|
+
);
|
|
384
|
+
return Array.isArray(rows) ? rows : [];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Open impersonation sessions. Admin-only on the server. */
|
|
388
|
+
async listActiveImpersonations(): Promise<ActiveImpersonation[]> {
|
|
389
|
+
const [rows] = await this.remote.query<[ActiveImpersonation[]]>('RETURN fn::_00_impersonate::active()');
|
|
390
|
+
return Array.isArray(rows) ? rows : [];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The target of an impersonation that ended, once. The bucket switch calls
|
|
395
|
+
* this after leaving a bucket so it can delete that user's local data.
|
|
396
|
+
*/
|
|
397
|
+
consumeEndedImpersonation(): string | null {
|
|
398
|
+
const target = this.endedImpersonationTarget;
|
|
399
|
+
this.endedImpersonationTarget = null;
|
|
400
|
+
return target;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Restore a session from the locally cached JWT, with NO network.
|
|
405
|
+
*
|
|
406
|
+
* This is what makes a warm boot paint instantly and what makes an offline
|
|
407
|
+
* boot possible at all: the token is in local storage, and it already carries
|
|
408
|
+
* both the access method and the `$auth.id` record id. Everything the client
|
|
409
|
+
* needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
|
|
410
|
+
* permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
|
|
411
|
+
* available before a socket exists.
|
|
412
|
+
*
|
|
413
|
+
* The session is OPTIMISTIC: the token is unverified here. `check()` runs
|
|
414
|
+
* afterwards in the background and downgrades to a real sign-out if the
|
|
415
|
+
* server rejects it. Nothing is trusted that the server has not also seen -
|
|
416
|
+
* the local store only ever holds rows the server previously sent.
|
|
417
|
+
*
|
|
418
|
+
* Returns the restored user id, or null when there is no usable token.
|
|
419
|
+
*/
|
|
420
|
+
async restoreSessionFromToken(): Promise<string | null> {
|
|
421
|
+
const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
|
|
422
|
+
if (!token) return null;
|
|
423
|
+
const { access, userId } = decodeTokenClaims(token);
|
|
424
|
+
if (!userId) return null;
|
|
425
|
+
|
|
426
|
+
this.token = token;
|
|
427
|
+
// Hand it to the transport too, so a socket rebuilt from scratch later
|
|
428
|
+
// (the supervisor's revive loop) comes back authenticated. Without this the
|
|
429
|
+
// page kept reporting this user while its session was anonymous, and every
|
|
430
|
+
// view registered afterwards was stamped with an empty identity.
|
|
431
|
+
this.remote.setAuthToken(token);
|
|
432
|
+
// Only the id: the full row is not in the token. It lands from the local
|
|
433
|
+
// cache when the app's own `user` query paints, and is replaced wholesale
|
|
434
|
+
// by `check()` once the server answers.
|
|
435
|
+
this.currentUser = { id: userId };
|
|
436
|
+
this.isAuthenticated = true;
|
|
437
|
+
this.access = access ?? this.defaultAccessName();
|
|
438
|
+
this.notifyListeners();
|
|
439
|
+
this.logger.debug(
|
|
440
|
+
{ userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
|
|
441
|
+
'Session restored optimistically from cached token'
|
|
442
|
+
);
|
|
443
|
+
return userId;
|
|
89
444
|
}
|
|
90
445
|
|
|
91
446
|
/**
|
|
@@ -94,8 +449,12 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
94
449
|
async check(accessToken?: string) {
|
|
95
450
|
this.isLoading = true;
|
|
96
451
|
|
|
452
|
+
let token: string | undefined;
|
|
453
|
+
// Set once the server has accepted the token: from then on a failure is a
|
|
454
|
+
// failure to read the user row, not a verdict on the session.
|
|
455
|
+
let accepted = false;
|
|
97
456
|
try {
|
|
98
|
-
|
|
457
|
+
token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
|
|
99
458
|
|
|
100
459
|
if (!token) {
|
|
101
460
|
this.logger.debug(
|
|
@@ -108,8 +467,12 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
108
467
|
return;
|
|
109
468
|
}
|
|
110
469
|
|
|
111
|
-
// Authenticate with the token
|
|
470
|
+
// Authenticate with the token, and record it for future connects so a
|
|
471
|
+
// socket rebuilt from scratch reproduces this identity (see
|
|
472
|
+
// `RemoteDatabaseService.setAuthToken`).
|
|
473
|
+
this.remote.setAuthToken(token);
|
|
112
474
|
await this.remote.getClient().authenticate(token);
|
|
475
|
+
accepted = true;
|
|
113
476
|
|
|
114
477
|
// Verify the session by fetching the full user record using $auth.id
|
|
115
478
|
const result = await this.remote.query('SELECT * FROM ONLY $auth.id');
|
|
@@ -149,33 +512,105 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
149
512
|
{ Category: 'sp00ky-client::AuthService::check' },
|
|
150
513
|
'Token valid but user not found via fallback'
|
|
151
514
|
);
|
|
152
|
-
await this.
|
|
515
|
+
await this.rejectToken(token);
|
|
153
516
|
}
|
|
154
517
|
}
|
|
155
518
|
} catch (error) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
519
|
+
// A REACHABILITY failure is not a rejected token. This catch used to call
|
|
520
|
+
// signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
|
|
521
|
+
// blip on boot silently logged the user out, and an offline boot could
|
|
522
|
+
// never stay signed in. Only an application error (the server answered,
|
|
523
|
+
// and the answer was "no") ends the session.
|
|
524
|
+
if (classifySyncError(error) === 'network') {
|
|
525
|
+
this.logger.warn(
|
|
526
|
+
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
527
|
+
'Auth check unreachable; keeping the cached session and retrying later'
|
|
528
|
+
);
|
|
529
|
+
} else if (accepted && token) {
|
|
530
|
+
// The server took the token and only the read of the user row failed
|
|
531
|
+
// (a busy database, a statement error). Signing out here logged an
|
|
532
|
+
// admin out on the way back from an impersonation while the database
|
|
533
|
+
// was saturated. Keep the session on the id the token names; the row
|
|
534
|
+
// arrives with the next check or the app's own user query.
|
|
535
|
+
this.logger.warn(
|
|
536
|
+
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
537
|
+
'Auth check accepted the token but could not read the user; keeping the session'
|
|
538
|
+
);
|
|
539
|
+
await this.keepAcceptedSession(token);
|
|
540
|
+
} else {
|
|
541
|
+
this.logger.error(
|
|
542
|
+
{ error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
|
|
543
|
+
'Auth check failed'
|
|
544
|
+
);
|
|
545
|
+
await this.rejectToken(token);
|
|
546
|
+
}
|
|
161
547
|
} finally {
|
|
162
548
|
this.isLoading = false;
|
|
163
549
|
}
|
|
164
550
|
}
|
|
165
551
|
|
|
552
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
553
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
554
|
+
private async keepAcceptedSession(token: string): Promise<void> {
|
|
555
|
+
if (this.isAuthenticated && this.token === token && this.currentUser) return;
|
|
556
|
+
const { userId } = decodeTokenClaims(token);
|
|
557
|
+
if (!userId) {
|
|
558
|
+
await this.rejectToken(token);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
await this.setSession(token, { id: userId });
|
|
562
|
+
}
|
|
563
|
+
|
|
166
564
|
/**
|
|
167
|
-
*
|
|
565
|
+
* The server refused `token`. A refused impersonation token (stopped,
|
|
566
|
+
* expired, or revoked) returns to the admin session instead of signing the
|
|
567
|
+
* admin out; anything else signs out.
|
|
568
|
+
*/
|
|
569
|
+
private async rejectToken(token: string | undefined): Promise<void> {
|
|
570
|
+
if (token && impersonationFromToken(token)) {
|
|
571
|
+
if (!this.impersonation || this.token !== token) {
|
|
572
|
+
// A boot or explicit check with an impersonation token that is not
|
|
573
|
+
// the session in memory yet: adopt it so stop has something to leave.
|
|
574
|
+
this.token = token;
|
|
575
|
+
this.isAuthenticated = true;
|
|
576
|
+
}
|
|
577
|
+
await this.stopImpersonating({ serverEnded: true });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
await this.signOut();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Sign out and clear session. While impersonating this ends the
|
|
585
|
+
* impersonation and signs the admin out too.
|
|
168
586
|
*/
|
|
169
587
|
async signOut() {
|
|
588
|
+
// Before anything is dropped: hooks still act as the leaving user.
|
|
589
|
+
await this.runSignOutHooks();
|
|
590
|
+
const impersonating = this.impersonation;
|
|
591
|
+
this.impersonationTimers.clear();
|
|
592
|
+
if (impersonating) {
|
|
593
|
+
this.endedImpersonationTarget = impersonating.target;
|
|
594
|
+
try {
|
|
595
|
+
await this.remote.query('RETURN fn::_00_impersonate::stop($session)', {
|
|
596
|
+
session: impersonating.session,
|
|
597
|
+
});
|
|
598
|
+
} catch (_e) {
|
|
599
|
+
// The token expires on its own.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
|
|
170
603
|
this.token = null;
|
|
604
|
+
this.remote.setAuthToken(null);
|
|
171
605
|
this.currentUser = null;
|
|
172
606
|
this.isAuthenticated = false;
|
|
607
|
+
this.access = null;
|
|
173
608
|
|
|
174
609
|
await this.persistenceClient.remove('sp00ky_auth_token');
|
|
175
610
|
|
|
176
611
|
try {
|
|
177
612
|
await this.remote.getClient().invalidate();
|
|
178
|
-
} catch (
|
|
613
|
+
} catch (_e) {
|
|
179
614
|
// Ignore invalidation errors
|
|
180
615
|
}
|
|
181
616
|
|
|
@@ -184,12 +619,24 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
184
619
|
|
|
185
620
|
private async setSession(token: string, user: any) {
|
|
186
621
|
this.token = token;
|
|
622
|
+
this.remote.setAuthToken(token);
|
|
187
623
|
this.currentUser = user;
|
|
188
624
|
this.isAuthenticated = true;
|
|
625
|
+
// Resolve the access-method name (e.g. "account") for in-browser SSP
|
|
626
|
+
// permission injection. Prefer the token's `AC` claim; fall back to the
|
|
627
|
+
// schema's sole record-access method if the claim is absent.
|
|
628
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
189
629
|
await this.persistenceClient.set('sp00ky_auth_token', token);
|
|
190
630
|
this.notifyListeners();
|
|
191
631
|
}
|
|
192
632
|
|
|
633
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
634
|
+
* exactly one record-access method, assume the session used it. */
|
|
635
|
+
private defaultAccessName(): string | null {
|
|
636
|
+
const names = Object.keys(this.schema.access ?? {});
|
|
637
|
+
return names.length === 1 ? names[0] : null;
|
|
638
|
+
}
|
|
639
|
+
|
|
193
640
|
async signUp<Name extends keyof S['access'] & string>(
|
|
194
641
|
accessName: Name,
|
|
195
642
|
params: ExtractAccessParams<S, Name, 'signup'>
|
|
@@ -211,11 +658,17 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
211
658
|
);
|
|
212
659
|
}
|
|
213
660
|
|
|
661
|
+
// Parameter names only: the values hold the password (and whatever else
|
|
662
|
+
// the app signs up with), and logs may leave the device.
|
|
214
663
|
this.logger.info(
|
|
215
|
-
{ accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
|
|
664
|
+
{ accessName, params: Object.keys(runtimeParams), Category: 'sp00ky-client::AuthService::signUp' },
|
|
216
665
|
'Attempting signup'
|
|
217
666
|
);
|
|
218
667
|
|
|
668
|
+
// Straight to the SDK client, so wait out a connect in flight: on a socket
|
|
669
|
+
// without its namespace yet the call fails with "There is no namespace
|
|
670
|
+
// and/or database selected" (a sign-up right after the page loaded).
|
|
671
|
+
await this.remote.whenConnected();
|
|
219
672
|
const { access } = await this.remote.getClient().signup({
|
|
220
673
|
access: accessName,
|
|
221
674
|
variables: runtimeParams,
|
|
@@ -257,6 +710,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
257
710
|
'Attempting signin'
|
|
258
711
|
);
|
|
259
712
|
|
|
713
|
+
await this.remote.whenConnected(); // see signUp
|
|
260
714
|
const { access } = await this.remote.getClient().signin({
|
|
261
715
|
access: accessName,
|
|
262
716
|
variables: runtimeParams,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type * as Loro from 'loro-crdt';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which edit-log rows may enter a document.
|
|
5
|
+
*
|
|
6
|
+
* Every client and the SSP fold (`packages/ssp-node/src/crdt/acceptance.rs`)
|
|
7
|
+
* apply this exact rule before importing a row, so they import the same rows
|
|
8
|
+
* and converge on the same document: a row one side accepted and another
|
|
9
|
+
* refused would leave them different forever (a CRDT never forgets an op).
|
|
10
|
+
*
|
|
11
|
+
* - the blob decodes as a Loro **update** (a snapshot could smuggle in
|
|
12
|
+
* arbitrary history from any peer);
|
|
13
|
+
* - it is no larger than the column allows;
|
|
14
|
+
* - every op in it was made by the row's `peer`, which the database already
|
|
15
|
+
* checked belongs to the row's author (`_00_crdt_peer`).
|
|
16
|
+
*
|
|
17
|
+
* Loro's decoder can trap on crafted input with a valid checksum, so a raw
|
|
18
|
+
* header check rejects non-update blobs before decoding, and decoding runs
|
|
19
|
+
* under `try`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type Rejection =
|
|
23
|
+
| 'empty'
|
|
24
|
+
| 'too_large'
|
|
25
|
+
| 'bad_peer'
|
|
26
|
+
| 'header'
|
|
27
|
+
| 'undecodable'
|
|
28
|
+
| 'not_an_update'
|
|
29
|
+
| 'no_ops'
|
|
30
|
+
| 'foreign_peer';
|
|
31
|
+
|
|
32
|
+
export interface Accepted {
|
|
33
|
+
peer: `${number}`;
|
|
34
|
+
/** Exclusive end counter of the row's ops for `peer`. */
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type Verdict = { ok: true; accepted: Accepted } | { ok: false; reason: Rejection };
|
|
39
|
+
|
|
40
|
+
/** The largest blob a row may carry (the generated column's cap). */
|
|
41
|
+
export const MAX_DELTA_BYTES = 256 * 1024;
|
|
42
|
+
|
|
43
|
+
const PEER = /^\d{1,20}$/;
|
|
44
|
+
/** `"loro"`: every Loro blob starts with it. */
|
|
45
|
+
const MAGIC = [0x6c, 0x6f, 0x72, 0x6f];
|
|
46
|
+
/** Header bytes 20..22 (big-endian) carry the encoding mode; 4 is an update. */
|
|
47
|
+
const MODE_UPDATES = 4;
|
|
48
|
+
|
|
49
|
+
export function check(
|
|
50
|
+
loro: Pick<typeof Loro, 'decodeImportBlobMeta'>,
|
|
51
|
+
blob: Uint8Array | undefined,
|
|
52
|
+
peer: unknown,
|
|
53
|
+
maxBytes = MAX_DELTA_BYTES
|
|
54
|
+
): Verdict {
|
|
55
|
+
if (!blob || blob.length === 0) return { ok: false, reason: 'empty' };
|
|
56
|
+
if (blob.length > maxBytes) return { ok: false, reason: 'too_large' };
|
|
57
|
+
if (typeof peer !== 'string' || !PEER.test(peer)) return { ok: false, reason: 'bad_peer' };
|
|
58
|
+
if (blob.length < 22 || MAGIC.some((b, i) => blob[i] !== b)) return { ok: false, reason: 'header' };
|
|
59
|
+
if (((blob[20] << 8) | blob[21]) !== MODE_UPDATES) return { ok: false, reason: 'not_an_update' };
|
|
60
|
+
let meta: ReturnType<typeof Loro.decodeImportBlobMeta>;
|
|
61
|
+
try {
|
|
62
|
+
meta = loro.decodeImportBlobMeta(blob, true);
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, reason: 'undecodable' };
|
|
65
|
+
}
|
|
66
|
+
if (meta.mode !== 'update') return { ok: false, reason: 'not_an_update' };
|
|
67
|
+
const start = meta.partialStartVersionVector.toJSON();
|
|
68
|
+
const authors: Array<[string, number]> = [];
|
|
69
|
+
for (const [p, end] of meta.partialEndVersionVector.toJSON()) {
|
|
70
|
+
if (end > (start.get(p) ?? 0)) authors.push([String(p), end]);
|
|
71
|
+
}
|
|
72
|
+
if (authors.length === 0) return { ok: false, reason: 'no_ops' };
|
|
73
|
+
if (authors.length > 1 || BigInt(authors[0][0]) !== BigInt(peer)) return { ok: false, reason: 'foreign_peer' };
|
|
74
|
+
return { ok: true, accepted: { peer: peer as `${number}`, end: authors[0][1] } };
|
|
75
|
+
}
|