@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- 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 +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- 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/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- 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 +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -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 +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +198 -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 +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { DataModule } from '../data/index';
|
|
3
|
+
import type { Sp00kySync } from '../sync/index';
|
|
4
|
+
import type { AuthService } from '../auth/index';
|
|
5
|
+
import type { Logger } from '../../services/logger/index';
|
|
6
|
+
import type { QueryTimeToLive } from '../../types';
|
|
7
|
+
import { semverGt } from '../../utils/semver';
|
|
8
|
+
|
|
9
|
+
// One shared LIVE query over every app's release row. `_00_app_release` is
|
|
10
|
+
// world-readable (root-only writes), one row per app keyed by name — written
|
|
11
|
+
// by `spky deploy` / `spky release` / the git-linked builder. A single
|
|
12
|
+
// registration observes every app at once; a handle for an app with no row
|
|
13
|
+
// simply reports no update. Mirrors the FeatureFlagModule design.
|
|
14
|
+
const RELEASE_QUERY = 'SELECT * FROM _00_app_release';
|
|
15
|
+
|
|
16
|
+
interface ReleaseRow {
|
|
17
|
+
app?: string;
|
|
18
|
+
version?: string;
|
|
19
|
+
cache_bust?: boolean | null;
|
|
20
|
+
mandatory?: boolean | null;
|
|
21
|
+
released_at?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AppReleaseSnapshot {
|
|
25
|
+
/** Latest announced version for the app, or undefined when no row exists. */
|
|
26
|
+
version: string | undefined;
|
|
27
|
+
/** Clients should clear SW/caches when reloading onto this version. */
|
|
28
|
+
cacheBust: boolean;
|
|
29
|
+
/** Clients should reload/update immediately instead of asking. */
|
|
30
|
+
mandatory: boolean;
|
|
31
|
+
releasedAt: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EMPTY_SNAPSHOT: AppReleaseSnapshot = {
|
|
35
|
+
version: undefined,
|
|
36
|
+
cacheBust: false,
|
|
37
|
+
mandatory: false,
|
|
38
|
+
releasedAt: undefined,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface AppReleaseOptions {
|
|
42
|
+
ttl?: QueryTimeToLive;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class AppReleaseHandle {
|
|
46
|
+
private latest: AppReleaseSnapshot = EMPTY_SNAPSHOT;
|
|
47
|
+
private listeners = new Set<(s: AppReleaseSnapshot) => void>();
|
|
48
|
+
private onCloseFn: (() => void) | null = null;
|
|
49
|
+
private closed = false;
|
|
50
|
+
|
|
51
|
+
constructor(public readonly app: string) {}
|
|
52
|
+
|
|
53
|
+
set(snapshot: AppReleaseSnapshot): void {
|
|
54
|
+
if (this.closed) return;
|
|
55
|
+
this.latest = snapshot;
|
|
56
|
+
for (const cb of this.listeners) cb(snapshot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
snapshot(): AppReleaseSnapshot {
|
|
60
|
+
return this.latest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
version(): string | undefined {
|
|
64
|
+
return this.latest.version;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when the announced version is semver-newer than `currentVersion`. */
|
|
68
|
+
updateAvailable(currentVersion: string): boolean {
|
|
69
|
+
return semverGt(this.latest.version, currentVersion);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
subscribe(cb: (s: AppReleaseSnapshot) => void): () => void {
|
|
73
|
+
this.listeners.add(cb);
|
|
74
|
+
cb(this.latest);
|
|
75
|
+
return () => {
|
|
76
|
+
this.listeners.delete(cb);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onClose(cb: () => void): void {
|
|
81
|
+
this.onCloseFn = cb;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(): void {
|
|
85
|
+
if (this.closed) return;
|
|
86
|
+
this.closed = true;
|
|
87
|
+
this.listeners.clear();
|
|
88
|
+
this.onCloseFn?.();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AppReleaseModuleDeps<S extends SchemaStructure> {
|
|
93
|
+
dataModule: DataModule<S>;
|
|
94
|
+
sync: Sp00kySync<S>;
|
|
95
|
+
auth: AuthService<S>;
|
|
96
|
+
logger: Logger;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class AppReleaseModule<S extends SchemaStructure> {
|
|
100
|
+
private logger: Logger;
|
|
101
|
+
private handles = new Set<AppReleaseHandle>();
|
|
102
|
+
private authUnsubscribe: (() => void) | null = null;
|
|
103
|
+
private lastUserId: string | null = null;
|
|
104
|
+
|
|
105
|
+
private querySubscription: (() => void) | null = null;
|
|
106
|
+
private starting = false;
|
|
107
|
+
private ttl: QueryTimeToLive = '10m';
|
|
108
|
+
private snapshots = new Map<string, AppReleaseSnapshot>();
|
|
109
|
+
private loaded = false;
|
|
110
|
+
|
|
111
|
+
constructor(private deps: AppReleaseModuleDeps<S>) {
|
|
112
|
+
this.logger = deps.logger.child({ service: 'AppReleaseModule' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
init(): void {
|
|
116
|
+
if (this.authUnsubscribe) return;
|
|
117
|
+
// Auth changes re-register the shared query (a new session invalidates the
|
|
118
|
+
// old SSP plan). The table itself is world-readable, so the data is the
|
|
119
|
+
// same for every user — this is purely plumbing hygiene.
|
|
120
|
+
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
121
|
+
if (userId === this.lastUserId) return;
|
|
122
|
+
this.lastUserId = userId;
|
|
123
|
+
void this.refresh();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
release(app: string, options: AppReleaseOptions = {}): AppReleaseHandle {
|
|
128
|
+
const handle = new AppReleaseHandle(app);
|
|
129
|
+
this.handles.add(handle);
|
|
130
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
131
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
132
|
+
if (this.loaded) {
|
|
133
|
+
handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
|
|
134
|
+
}
|
|
135
|
+
void this.ensureStarted();
|
|
136
|
+
return handle;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async closeAll(): Promise<void> {
|
|
140
|
+
this.authUnsubscribe?.();
|
|
141
|
+
this.authUnsubscribe = null;
|
|
142
|
+
this.teardownQuery();
|
|
143
|
+
for (const handle of [...this.handles]) handle.close();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async refresh(): Promise<void> {
|
|
147
|
+
this.teardownQuery();
|
|
148
|
+
this.loaded = false;
|
|
149
|
+
this.snapshots.clear();
|
|
150
|
+
await this.ensureStarted();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private teardownQuery(): void {
|
|
154
|
+
this.querySubscription?.();
|
|
155
|
+
this.querySubscription = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async ensureStarted(): Promise<void> {
|
|
159
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
160
|
+
this.starting = true;
|
|
161
|
+
try {
|
|
162
|
+
const hash = await this.deps.dataModule.query(
|
|
163
|
+
'_00_app_release' as any,
|
|
164
|
+
RELEASE_QUERY,
|
|
165
|
+
{},
|
|
166
|
+
this.ttl,
|
|
167
|
+
);
|
|
168
|
+
this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
169
|
+
this.querySubscription = this.deps.dataModule.subscribe(
|
|
170
|
+
hash,
|
|
171
|
+
(records) => this.applyRecords(records as ReleaseRow[]),
|
|
172
|
+
{ immediate: true },
|
|
173
|
+
);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
this.logger.warn(
|
|
176
|
+
{ err, Category: 'sp00ky-client::AppReleaseModule::register' },
|
|
177
|
+
'Failed to register app release query',
|
|
178
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
this.starting = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private applyRecords(records: ReleaseRow[]): void {
|
|
185
|
+
this.snapshots.clear();
|
|
186
|
+
for (const row of records ?? []) {
|
|
187
|
+
if (row && typeof row.app === 'string' && typeof row.version === 'string') {
|
|
188
|
+
this.snapshots.set(row.app, {
|
|
189
|
+
version: row.version,
|
|
190
|
+
cacheBust: row.cache_bust === true,
|
|
191
|
+
mandatory: row.mandatory === true,
|
|
192
|
+
releasedAt: row.released_at,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.loaded = true;
|
|
197
|
+
for (const handle of this.handles) {
|
|
198
|
+
handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { AuthService } from './index';
|
|
3
|
+
|
|
4
|
+
// A SurrealDB record-access JWT carries the access method as `AC` and the
|
|
5
|
+
// `$auth.id` record id as `ID`. Only the payload matters here - nothing in the
|
|
6
|
+
// client verifies the signature, the server does.
|
|
7
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
8
|
+
const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
9
|
+
return `header.${b64}.signature`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const silentLogger = () =>
|
|
13
|
+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
|
|
14
|
+
|
|
15
|
+
function makeAuth(opts: { token?: string | null; query?: any; authenticate?: any } = {}) {
|
|
16
|
+
const store = new Map<string, unknown>();
|
|
17
|
+
if (opts.token) store.set('sp00ky_auth_token', opts.token);
|
|
18
|
+
const persistence = {
|
|
19
|
+
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
20
|
+
set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
|
|
21
|
+
remove: vi.fn(async (k: string) => void store.delete(k)),
|
|
22
|
+
} as any;
|
|
23
|
+
const remote = {
|
|
24
|
+
query: opts.query ?? vi.fn(async () => [[]]),
|
|
25
|
+
getClient: () => ({ authenticate: opts.authenticate ?? vi.fn(async () => undefined) }),
|
|
26
|
+
} as any;
|
|
27
|
+
return { auth: new AuthService({} as any, remote, persistence, silentLogger()), remote, persistence, store };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('restoreSessionFromToken', () => {
|
|
31
|
+
it('restores the session from the cached token with NO network', async () => {
|
|
32
|
+
const { auth, remote } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
|
|
33
|
+
|
|
34
|
+
const userId = await auth.restoreSessionFromToken();
|
|
35
|
+
|
|
36
|
+
expect(userId).toBe('user:abc');
|
|
37
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
38
|
+
expect(auth.currentUser?.id).toBe('user:abc');
|
|
39
|
+
expect(auth.access).toBe('account');
|
|
40
|
+
// The whole point: this is what makes a warm/offline boot paint.
|
|
41
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('notifies subscribers so query routing can be set before any registration', async () => {
|
|
45
|
+
const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
|
|
46
|
+
const seen: (string | null)[] = [];
|
|
47
|
+
auth.subscribe((uid) => seen.push(uid));
|
|
48
|
+
|
|
49
|
+
await auth.restoreSessionFromToken();
|
|
50
|
+
|
|
51
|
+
expect(seen).toContain('user:abc');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null when there is no token, and when the token carries no id', async () => {
|
|
55
|
+
expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
|
|
56
|
+
const noId = makeAuth({ token: jwt({ AC: 'account' }) });
|
|
57
|
+
expect(await noId.auth.restoreSessionFromToken()).toBeNull();
|
|
58
|
+
expect(noId.auth.isAuthenticated).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('survives a malformed token rather than throwing', async () => {
|
|
62
|
+
const { auth } = makeAuth({ token: 'not-a-jwt' });
|
|
63
|
+
expect(await auth.restoreSessionFromToken()).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('check() error handling', () => {
|
|
68
|
+
const token = jwt({ AC: 'account', ID: 'user:abc' });
|
|
69
|
+
|
|
70
|
+
it('KEEPS the cached session when the server is unreachable', async () => {
|
|
71
|
+
const { auth, persistence, store } = makeAuth({
|
|
72
|
+
token,
|
|
73
|
+
authenticate: vi.fn(async () => {
|
|
74
|
+
throw new Error('There was a problem with the underlying connection');
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
await auth.restoreSessionFromToken();
|
|
78
|
+
|
|
79
|
+
await auth.check();
|
|
80
|
+
|
|
81
|
+
// A blip must not log the user out - that is what makes offline possible.
|
|
82
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
83
|
+
expect(store.get('sp00ky_auth_token')).toBe(token);
|
|
84
|
+
expect(persistence.remove).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('signs out for real when the server REJECTS the token', async () => {
|
|
88
|
+
const { auth, store } = makeAuth({
|
|
89
|
+
token,
|
|
90
|
+
authenticate: vi.fn(async () => {
|
|
91
|
+
throw new Error('There was a problem with the database: Invalid token');
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
await auth.restoreSessionFromToken();
|
|
95
|
+
|
|
96
|
+
await auth.check();
|
|
97
|
+
|
|
98
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
99
|
+
expect(store.get('sp00ky_auth_token')).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { EventDefinition, EventSystem } from '../../../events/index';
|
|
2
|
+
import { createEventSystem } from '../../../events/index';
|
|
2
3
|
|
|
3
4
|
export const AuthEventTypes = {
|
|
4
5
|
AuthStateChanged: 'AUTH_STATE_CHANGED',
|
|
@@ -1,17 +1,15 @@
|
|
|
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';
|
|
15
13
|
|
|
16
14
|
// Helper to pretty print types
|
|
17
15
|
type Prettify<T> = {
|
|
@@ -38,11 +36,54 @@ type ExtractAccessParams<
|
|
|
38
36
|
}>
|
|
39
37
|
: never;
|
|
40
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
41
|
+
* server still enforces the token on every request; this is only so the client
|
|
42
|
+
* can act on what it already holds before a round trip completes.
|
|
43
|
+
*
|
|
44
|
+
* `AC` is the access-method name — the in-browser SSP needs it to resolve
|
|
45
|
+
* `$access` in table permission predicates (mirrors the session's `$access`
|
|
46
|
+
* that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
|
|
47
|
+
* id, which is what lets a warm boot restore a session locally.
|
|
48
|
+
*
|
|
49
|
+
* Returns nulls on any malformed input.
|
|
50
|
+
*/
|
|
51
|
+
function decodeTokenClaims(token: string): { access: string | null; userId: string | null } {
|
|
52
|
+
try {
|
|
53
|
+
const payload = token.split('.')[1];
|
|
54
|
+
if (!payload) return { access: null, userId: null };
|
|
55
|
+
let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
56
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
57
|
+
const json =
|
|
58
|
+
typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
|
|
59
|
+
const claims = JSON.parse(json) as Record<string, unknown>;
|
|
60
|
+
const ac = claims.AC ?? claims.ac;
|
|
61
|
+
const id = claims.ID ?? claims.id;
|
|
62
|
+
return {
|
|
63
|
+
access: typeof ac === 'string' ? ac : null,
|
|
64
|
+
userId: typeof id === 'string' ? id : null,
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return { access: null, userId: null };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decodeAccessFromToken(token: string): string | null {
|
|
72
|
+
return decodeTokenClaims(token).access;
|
|
73
|
+
}
|
|
74
|
+
|
|
41
75
|
export class AuthService<S extends SchemaStructure> {
|
|
42
76
|
// State
|
|
43
77
|
public token: string | null = null;
|
|
44
78
|
public currentUser: any | null = null;
|
|
45
79
|
public isAuthenticated: boolean = false;
|
|
80
|
+
/**
|
|
81
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
82
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
83
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
84
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
85
|
+
*/
|
|
86
|
+
public access: string | null = null;
|
|
46
87
|
public isLoading: boolean = true;
|
|
47
88
|
|
|
48
89
|
private events = createAuthEventSystem();
|
|
@@ -88,6 +129,44 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
88
129
|
this.events.emit(AuthEventTypes.AuthStateChanged, userId);
|
|
89
130
|
}
|
|
90
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Restore a session from the locally cached JWT, with NO network.
|
|
134
|
+
*
|
|
135
|
+
* This is what makes a warm boot paint instantly and what makes an offline
|
|
136
|
+
* boot possible at all: the token is in local storage, and it already carries
|
|
137
|
+
* both the access method and the `$auth.id` record id. Everything the client
|
|
138
|
+
* needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
|
|
139
|
+
* permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
|
|
140
|
+
* available before a socket exists.
|
|
141
|
+
*
|
|
142
|
+
* The session is OPTIMISTIC: the token is unverified here. `check()` runs
|
|
143
|
+
* afterwards in the background and downgrades to a real sign-out if the
|
|
144
|
+
* server rejects it. Nothing is trusted that the server has not also seen -
|
|
145
|
+
* the local store only ever holds rows the server previously sent.
|
|
146
|
+
*
|
|
147
|
+
* Returns the restored user id, or null when there is no usable token.
|
|
148
|
+
*/
|
|
149
|
+
async restoreSessionFromToken(): Promise<string | null> {
|
|
150
|
+
const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
|
|
151
|
+
if (!token) return null;
|
|
152
|
+
const { access, userId } = decodeTokenClaims(token);
|
|
153
|
+
if (!userId) return null;
|
|
154
|
+
|
|
155
|
+
this.token = token;
|
|
156
|
+
// Only the id: the full row is not in the token. It lands from the local
|
|
157
|
+
// cache when the app's own `user` query paints, and is replaced wholesale
|
|
158
|
+
// by `check()` once the server answers.
|
|
159
|
+
this.currentUser = { id: userId };
|
|
160
|
+
this.isAuthenticated = true;
|
|
161
|
+
this.access = access ?? this.defaultAccessName();
|
|
162
|
+
this.notifyListeners();
|
|
163
|
+
this.logger.debug(
|
|
164
|
+
{ userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
|
|
165
|
+
'Session restored optimistically from cached token'
|
|
166
|
+
);
|
|
167
|
+
return userId;
|
|
168
|
+
}
|
|
169
|
+
|
|
91
170
|
/**
|
|
92
171
|
* Check for existing session and validate
|
|
93
172
|
*/
|
|
@@ -95,11 +174,11 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
95
174
|
this.isLoading = true;
|
|
96
175
|
|
|
97
176
|
try {
|
|
98
|
-
const token = accessToken || (await this.persistenceClient.get<string>('
|
|
177
|
+
const token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token'));
|
|
99
178
|
|
|
100
179
|
if (!token) {
|
|
101
180
|
this.logger.debug(
|
|
102
|
-
{ Category: '
|
|
181
|
+
{ Category: 'sp00ky-client::AuthService::check' },
|
|
103
182
|
'No token found in storage or arguments'
|
|
104
183
|
);
|
|
105
184
|
this.isLoading = false;
|
|
@@ -119,13 +198,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
119
198
|
|
|
120
199
|
if (user && user.id) {
|
|
121
200
|
this.logger.info(
|
|
122
|
-
{ user, Category: '
|
|
201
|
+
{ user, Category: 'sp00ky-client::AuthService::check' },
|
|
123
202
|
'Auth check complete (via $auth.id)'
|
|
124
203
|
);
|
|
125
204
|
await this.setSession(token, user);
|
|
126
205
|
} else {
|
|
127
206
|
this.logger.warn(
|
|
128
|
-
{ Category: '
|
|
207
|
+
{ Category: 'sp00ky-client::AuthService::check' },
|
|
129
208
|
'$auth.id empty, attempting manual user fetch'
|
|
130
209
|
);
|
|
131
210
|
|
|
@@ -140,24 +219,36 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
140
219
|
|
|
141
220
|
if (manualUser && manualUser.id) {
|
|
142
221
|
this.logger.info(
|
|
143
|
-
{ user: manualUser, Category: '
|
|
222
|
+
{ user: manualUser, Category: 'sp00ky-client::AuthService::check' },
|
|
144
223
|
'Auth check complete (via manual fetch)'
|
|
145
224
|
);
|
|
146
225
|
await this.setSession(token, manualUser);
|
|
147
226
|
} else {
|
|
148
227
|
this.logger.warn(
|
|
149
|
-
{ Category: '
|
|
228
|
+
{ Category: 'sp00ky-client::AuthService::check' },
|
|
150
229
|
'Token valid but user not found via fallback'
|
|
151
230
|
);
|
|
152
231
|
await this.signOut();
|
|
153
232
|
}
|
|
154
233
|
}
|
|
155
234
|
} catch (error) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
235
|
+
// A REACHABILITY failure is not a rejected token. This catch used to call
|
|
236
|
+
// signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
|
|
237
|
+
// blip on boot silently logged the user out, and an offline boot could
|
|
238
|
+
// never stay signed in. Only an application error (the server answered,
|
|
239
|
+
// and the answer was "no") ends the session.
|
|
240
|
+
if (classifySyncError(error) === 'network') {
|
|
241
|
+
this.logger.warn(
|
|
242
|
+
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
243
|
+
'Auth check unreachable; keeping the cached session and retrying later'
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
this.logger.error(
|
|
247
|
+
{ error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
|
|
248
|
+
'Auth check failed'
|
|
249
|
+
);
|
|
250
|
+
await this.signOut();
|
|
251
|
+
}
|
|
161
252
|
} finally {
|
|
162
253
|
this.isLoading = false;
|
|
163
254
|
}
|
|
@@ -170,12 +261,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
170
261
|
this.token = null;
|
|
171
262
|
this.currentUser = null;
|
|
172
263
|
this.isAuthenticated = false;
|
|
264
|
+
this.access = null;
|
|
173
265
|
|
|
174
|
-
await this.persistenceClient.remove('
|
|
266
|
+
await this.persistenceClient.remove('sp00ky_auth_token');
|
|
175
267
|
|
|
176
268
|
try {
|
|
177
269
|
await this.remote.getClient().invalidate();
|
|
178
|
-
} catch (
|
|
270
|
+
} catch (_e) {
|
|
179
271
|
// Ignore invalidation errors
|
|
180
272
|
}
|
|
181
273
|
|
|
@@ -186,10 +278,21 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
186
278
|
this.token = token;
|
|
187
279
|
this.currentUser = user;
|
|
188
280
|
this.isAuthenticated = true;
|
|
189
|
-
|
|
281
|
+
// Resolve the access-method name (e.g. "account") for in-browser SSP
|
|
282
|
+
// permission injection. Prefer the token's `AC` claim; fall back to the
|
|
283
|
+
// schema's sole record-access method if the claim is absent.
|
|
284
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
285
|
+
await this.persistenceClient.set('sp00ky_auth_token', token);
|
|
190
286
|
this.notifyListeners();
|
|
191
287
|
}
|
|
192
288
|
|
|
289
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
290
|
+
* exactly one record-access method, assume the session used it. */
|
|
291
|
+
private defaultAccessName(): string | null {
|
|
292
|
+
const names = Object.keys(this.schema.access ?? {});
|
|
293
|
+
return names.length === 1 ? names[0] : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
193
296
|
async signUp<Name extends keyof S['access'] & string>(
|
|
194
297
|
accessName: Name,
|
|
195
298
|
params: ExtractAccessParams<S, Name, 'signup'>
|
|
@@ -212,7 +315,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
212
315
|
}
|
|
213
316
|
|
|
214
317
|
this.logger.info(
|
|
215
|
-
{ accessName, runtimeParams, Category: '
|
|
318
|
+
{ accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
|
|
216
319
|
'Attempting signup'
|
|
217
320
|
);
|
|
218
321
|
|
|
@@ -222,7 +325,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
222
325
|
});
|
|
223
326
|
|
|
224
327
|
this.logger.info(
|
|
225
|
-
{ Category: '
|
|
328
|
+
{ Category: 'sp00ky-client::AuthService::signUp' },
|
|
226
329
|
'Signup successful, token received'
|
|
227
330
|
);
|
|
228
331
|
|
|
@@ -253,7 +356,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
253
356
|
}
|
|
254
357
|
|
|
255
358
|
this.logger.info(
|
|
256
|
-
{ accessName, Category: '
|
|
359
|
+
{ accessName, Category: 'sp00ky-client::AuthService::signIn' },
|
|
257
360
|
'Attempting signin'
|
|
258
361
|
);
|
|
259
362
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { CacheModule } from './index';
|
|
4
|
+
|
|
5
|
+
// The ingest relay is how one tab's circuit learns what another tab wrote.
|
|
6
|
+
// The leader relays every ingest (its sync fetches are the only copy the
|
|
7
|
+
// followers get); a follower relays with `localWritesOnly`, just its mutation
|
|
8
|
+
// path, because its sync-fetched batches are the leader's data coming back.
|
|
9
|
+
|
|
10
|
+
function makeLogger(): any {
|
|
11
|
+
const logger: any = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {} };
|
|
12
|
+
logger.child = () => logger;
|
|
13
|
+
return logger;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setup() {
|
|
17
|
+
const local: any = { epoch: 1, execute: vi.fn(async () => {}), query: vi.fn(async () => []) };
|
|
18
|
+
const ssp: any = {
|
|
19
|
+
addReceiver: vi.fn(),
|
|
20
|
+
ingestMany: vi.fn((records: unknown[]) => records),
|
|
21
|
+
};
|
|
22
|
+
const cache = new CacheModule(local, ssp, () => {}, makeLogger());
|
|
23
|
+
const relay = vi.fn();
|
|
24
|
+
return { cache, local, ssp, relay };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rec = (id: string, version: number) => ({
|
|
28
|
+
table: 'thread',
|
|
29
|
+
op: 'CREATE' as const,
|
|
30
|
+
record: { id: new RecordId('thread', id), title: 't' },
|
|
31
|
+
version,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('CacheModule ingest relay', () => {
|
|
35
|
+
it('applyRelayedIngest updates the version memo and never re-relays', () => {
|
|
36
|
+
const { cache, ssp, relay } = setup();
|
|
37
|
+
cache.setIngestRelay(relay);
|
|
38
|
+
|
|
39
|
+
cache.applyRelayedIngest([
|
|
40
|
+
{ table: 'thread', op: 'CREATE', id: 'thread:a', record: { id: 'thread:a', _00_rv: 4 } },
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
expect(ssp.ingestMany).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(cache.lookup('thread:a')).toBe(4);
|
|
45
|
+
expect(relay).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('localWritesOnly relays the mutation path (save + delete)', async () => {
|
|
49
|
+
const { cache, local, relay } = setup();
|
|
50
|
+
cache.setIngestRelay(relay, { localWritesOnly: true });
|
|
51
|
+
|
|
52
|
+
await cache.save(rec('a', 1), true);
|
|
53
|
+
expect(local.execute).not.toHaveBeenCalled();
|
|
54
|
+
expect(relay).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(relay.mock.calls[0][0][0]).toMatchObject({ table: 'thread', op: 'CREATE', id: 'thread:a' });
|
|
56
|
+
|
|
57
|
+
const before = { id: 'thread:a', title: 't' };
|
|
58
|
+
await cache.delete('thread', 'thread:a', true, before);
|
|
59
|
+
expect(relay).toHaveBeenCalledTimes(2);
|
|
60
|
+
expect(relay.mock.calls[1][0]).toEqual([
|
|
61
|
+
{ table: 'thread', op: 'DELETE', id: 'thread:a', record: before },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('localWritesOnly stays silent for sync-fetched batches', async () => {
|
|
66
|
+
const { cache, local, relay } = setup();
|
|
67
|
+
cache.setIngestRelay(relay, { localWritesOnly: true });
|
|
68
|
+
|
|
69
|
+
await cache.saveBatch([rec('a', 1), rec('b', 1)], false);
|
|
70
|
+
expect(local.execute).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(relay).not.toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
await cache.delete('thread', 'thread:a', false, {});
|
|
74
|
+
expect(relay).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('an unscoped relay fires for every ingest', async () => {
|
|
78
|
+
const { cache, relay } = setup();
|
|
79
|
+
cache.setIngestRelay(relay);
|
|
80
|
+
|
|
81
|
+
await cache.saveBatch([rec('a', 1)], false);
|
|
82
|
+
await cache.save(rec('b', 1), true);
|
|
83
|
+
await cache.delete('thread', 'thread:a', false, {});
|
|
84
|
+
|
|
85
|
+
expect(relay).toHaveBeenCalledTimes(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('setIngestRelay(null) stops relaying', async () => {
|
|
89
|
+
const { cache, relay } = setup();
|
|
90
|
+
cache.setIngestRelay(relay, { localWritesOnly: true });
|
|
91
|
+
cache.setIngestRelay(null);
|
|
92
|
+
await cache.save(rec('a', 1), true);
|
|
93
|
+
expect(relay).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|