@spooky-sync/core 0.0.1-canary.250 → 0.0.1-canary.255

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 CHANGED
@@ -50,6 +50,7 @@ re-materialized from state, never patched in place.
50
50
  - **`db.update` takes `(table, id, payload, options?)`**. The `debounced` option (`{ debounced: true }` or `{ debounced: { delay, key } }`) coalesces rapid updates — use it for CRDT text/title fields.
51
51
  - **`db.remoteQuery(sql, vars?)` is the one-shot remote read.** It runs one SurrealQL statement through the client's remote path (connect gate, per-statement timeout, concurrency limit) and returns the SurrealDB result array; results are not synced into the local cache. Use it for counts and other reads the query builder can't express.
52
52
  - **`db.useRemote(fn)` hands out the bare SDK client.** Same cache bypass, but it skips the connect gate (a call during a reconnect fails with "Specify a namespace to use"). Reserve it for SDK features `remoteQuery` cannot reach (transactions, `live`).
53
+ - **Raw remote calls are gated by the query allowlist.** When the generated schema carries `policy.queryAllowlist: 'warn' | 'enforce'` (from `sync.queryAllowlist` in `sp00ky.yml`), `remoteQuery`, `useRemote` and `queryRaw` throw unless the client config sets `allowRawRemote: true`; builder queries are unaffected. A registration the SSP refuses as not allowlisted (403 `not_allowlisted`) is marked `remote: 'failed'` after one attempt with an error-level log carrying the surql and a `spky generate` hint; the view stays cold (`membershipKnown` false), it is never read as confirmed empty.
53
54
  - **`@parent` columns are auto-populated.** Defined in the `.surql` schema as `record<user>` with the `-- @parent` annotation; never write them yourself — they're set from the auth context server-side.
54
55
  - **`db.preload` is a registered query.** Resolved before on this device (a `_00_view` row exists) it returns at once; never resolved it waits until the server's membership and every body are local. It holds no subscriber, so it is evicted a ttl after registration unless a view mounts the same query. `refresh`/`staleTime` are ignored; pass `signal` to abort a cold wait.
55
56
  - **A rejected write is not lost.** Application errors roll the local row back (create/update/delete) and move the mutation to `_00_failed_mutations`; the tray API above lists, retries (as a fresh write) or discards it. Network errors keep the row queued.
package/dist/index.d.ts CHANGED
@@ -2255,6 +2255,12 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2255
2255
  query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
2256
2256
  private registerInput;
2257
2257
  private initQuery;
2258
+ /**
2259
+ * The raw remote escape hatches bypass the query builder, so their shapes
2260
+ * can never be on the server's query allowlist. When the generated schema
2261
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
2262
+ */
2263
+ private assertRawRemoteAllowed;
2258
2264
  queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string>;
2259
2265
  /**
2260
2266
  * Preload = register a query nobody subscribes to. Resolved before on this
package/dist/index.js CHANGED
@@ -1073,8 +1073,8 @@ function selfAllowlistedVariant(flag, userId) {
1073
1073
 
1074
1074
  //#endregion
1075
1075
  //#region src/modules/devtools/index.ts
1076
- const CORE_VERSION = "0.0.1-canary.250";
1077
- const WASM_VERSION = "0.0.1-canary.250";
1076
+ const CORE_VERSION = "0.0.1-canary.255";
1077
+ const WASM_VERSION = "0.0.1-canary.255";
1078
1078
  const SURREAL_VERSION = "3.0.3";
1079
1079
  var DevToolsService = class DevToolsService {
1080
1080
  eventsHistory = [];
@@ -3219,6 +3219,13 @@ function* ensureRegistered(env, opts = {}) {
3219
3219
  }
3220
3220
  const stmt = (results, i) => results[i];
3221
3221
  /**
3222
+ * The SSP answers a registration whose shape is not on the query allowlist
3223
+ * with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
3224
+ * `fn::query::register` statement error carrying SurrealDB's http error text
3225
+ * (status reason and/or body), and no retry can change the answer.
3226
+ */
3227
+ const isNotAllowlisted = (error) => /not_allowlisted|\b403\b|forbidden/i.test(String(error?.message ?? error));
3228
+ /**
3222
3229
  * One remote registration: `fn::query::register` plus the edge/meta/children
3223
3230
  * read in ONE request, then membership application. Concurrent with every
3224
3231
  * other registration; retried on its own backoff; stops when the entry is
@@ -3260,6 +3267,27 @@ function* registerRemote(env, hash, retry = false) {
3260
3267
  } catch (error) {
3261
3268
  const current = yield fx.state.read((s) => s.queries.get(hash));
3262
3269
  if (!current) return;
3270
+ if (isNotAllowlisted(error)) {
3271
+ yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
3272
+ yield fx.emit({
3273
+ type: "log",
3274
+ level: "error",
3275
+ message: "query refused: not allowlisted",
3276
+ data: {
3277
+ hash,
3278
+ table: entry.def.tableName,
3279
+ surql: entry.def.surql,
3280
+ params: Object.keys(entry.def.params),
3281
+ hint: "Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev)."
3282
+ }
3283
+ });
3284
+ yield fx.dispatch({
3285
+ type: "SyncOutcome",
3286
+ ok: false,
3287
+ error
3288
+ });
3289
+ return;
3290
+ }
3263
3291
  const attempts = current.registerAttempts + 1;
3264
3292
  yield fx.state.update(bumpRegisterAttempts(hash));
3265
3293
  yield fx.emit({
@@ -12516,7 +12544,7 @@ function createTabsCoordinator(config, s, host) {
12516
12544
  return new TabsCoordinator({
12517
12545
  tabId: s.tabId,
12518
12546
  fingerprint: computeTabsFingerprint({
12519
- coreVersion: "0.0.1-canary.250",
12547
+ coreVersion: "0.0.1-canary.255",
12520
12548
  schemaHash: hash53(config.schemaSurql),
12521
12549
  endpoint: config.database.endpoint ?? "",
12522
12550
  namespace: config.database.namespace,
@@ -12993,7 +13021,17 @@ var Sp00kyClient = class {
12993
13021
  initQuery(table, q, ttl) {
12994
13022
  return this.runtime.run(registerLocal(this.env, this.registerInput(table, q, ttl)));
12995
13023
  }
13024
+ /**
13025
+ * The raw remote escape hatches bypass the query builder, so their shapes
13026
+ * can never be on the server's query allowlist. When the generated schema
13027
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
13028
+ */
13029
+ assertRawRemoteAllowed(method) {
13030
+ const mode = this.config.schema.policy?.queryAllowlist;
13031
+ if ((mode === "warn" || mode === "enforce") && !this.config.allowRawRemote) throw new Error(`sp00ky: ${method} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`);
13032
+ }
12996
13033
  async queryRaw(sql, params, ttl) {
13034
+ this.assertRawRemoteAllowed("queryRaw");
12997
13035
  const tableName = sql.split("FROM ")[1]?.split(" ")[0] ?? "";
12998
13036
  return this.runtime.run(registerLocal(this.env, {
12999
13037
  tableName,
@@ -13144,9 +13182,11 @@ var Sp00kyClient = class {
13144
13182
  return this.services.remote.getClient().invalidate();
13145
13183
  }
13146
13184
  async useRemote(fn) {
13185
+ this.assertRawRemoteAllowed("useRemote");
13147
13186
  return fn(this.services.remote.getClient());
13148
13187
  }
13149
13188
  async remoteQuery(sql, vars) {
13189
+ this.assertRawRemoteAllowed("remoteQuery");
13150
13190
  return this.services.remote.query(sql, vars);
13151
13191
  }
13152
13192
  feature(key, options) {
package/dist/types.d.ts CHANGED
@@ -628,6 +628,15 @@ interface Sp00kyConfig<S extends SchemaStructure> {
628
628
  * one-shot but never sync live.
629
629
  */
630
630
  enableAnonymousLiveQueries?: boolean;
631
+ /**
632
+ * Opt in to the raw remote escape hatches (`useRemote`, `remoteQuery`,
633
+ * `queryRaw`) when the generated schema's `policy.queryAllowlist` is `warn`
634
+ * or `enforce`. With the allowlist on, the server only accepts query shapes
635
+ * that `spky generate` collected from the query builder, so a hand-written
636
+ * SurrealQL string cannot be allowlisted and these methods throw unless this
637
+ * flag is `true`. Defaults to `false`.
638
+ */
639
+ allowRawRemote?: boolean;
631
640
  /**
632
641
  * Surface sustained sync failures as a "degraded" health status that the app
633
642
  * can observe via `subscribeToSyncHealth` (or the client-solid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.250",
3
+ "version": "0.0.1-canary.255",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -63,8 +63,8 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "@spooky-sync/query-builder": "0.0.1-canary.250",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.250",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.255",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.255",
68
68
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
69
  "@surrealdb/wasm": "^3.0.3",
70
70
  "blurhash": "^2.0.5",
@@ -225,6 +225,29 @@ describe('Sp00kyClient facade (builder, devtools source, event fan-out)', () =>
225
225
  runtime.emit({ type: 'activity:changed', fetching: 0, pending: 4 });
226
226
  expect(pending).toEqual([0, 4]);
227
227
  });
228
+ it('gates queryRaw/useRemote/remoteQuery behind allowRawRemote when the schema policy turns the allowlist on', async () => {
229
+ const services = fakeServiceBundle<any>();
230
+ const a = fakeAdapters({});
231
+ const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
232
+ const withPolicy = (mode: string | undefined, allowRawRemote?: boolean) =>
233
+ new Sp00kyClient<any>(
234
+ { ...config, schema: mode === undefined ? schema : { ...schema, policy: { queryAllowlist: mode } }, allowRawRemote } as any,
235
+ { services, runtime }
236
+ );
237
+ const msg = (m: string) => `sp00ky: ${m} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`;
238
+ for (const mode of ['warn', 'enforce']) {
239
+ const gated = withPolicy(mode);
240
+ await expect(gated.queryRaw('SELECT * FROM thing', {}, '10m')).rejects.toThrow(msg('queryRaw'));
241
+ await expect(gated.useRemote(() => 1)).rejects.toThrow(msg('useRemote'));
242
+ await expect(gated.remoteQuery('RETURN 1')).rejects.toThrow(msg('remoteQuery'));
243
+ expect(a.names().filter((n) => n === 'remote.query')).toEqual([]);
244
+ }
245
+ for (const client of [withPolicy('enforce', true), withPolicy('off'), withPolicy(undefined)]) {
246
+ await expect(client.useRemote(() => 1)).resolves.toBe(1);
247
+ await expect(client.remoteQuery('RETURN 1')).resolves.toBeDefined();
248
+ }
249
+ });
250
+
228
251
  it('honours config knobs when building the saga env', () => {
229
252
  const services = fakeServiceBundle<any>();
230
253
  const a = fakeAdapters();
@@ -269,7 +269,22 @@ export class Sp00kyClient<S extends SchemaStructure> {
269
269
  return this.runtime.run(registerLocal(this.env, this.registerInput(table, q, ttl)));
270
270
  }
271
271
 
272
+ /**
273
+ * The raw remote escape hatches bypass the query builder, so their shapes
274
+ * can never be on the server's query allowlist. When the generated schema
275
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
276
+ */
277
+ private assertRawRemoteAllowed(method: string): void {
278
+ const mode = this.config.schema.policy?.queryAllowlist;
279
+ if ((mode === 'warn' || mode === 'enforce') && !this.config.allowRawRemote) {
280
+ throw new Error(
281
+ `sp00ky: ${method} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`
282
+ );
283
+ }
284
+ }
285
+
272
286
  async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string> {
287
+ this.assertRawRemoteAllowed('queryRaw');
273
288
  const tableName = sql.split('FROM ')[1]?.split(' ')[0] ?? '';
274
289
  return this.runtime.run(registerLocal(this.env, { tableName, surql: sql, params, ttl }));
275
290
  }
@@ -436,10 +451,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
436
451
  }
437
452
 
438
453
  async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
454
+ this.assertRawRemoteAllowed('useRemote');
439
455
  return fn(this.services.remote.getClient());
440
456
  }
441
457
 
442
458
  async remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
459
+ this.assertRawRemoteAllowed('remoteQuery');
443
460
  return this.services.remote.query<T>(sql, vars);
444
461
  }
445
462
 
@@ -4,7 +4,7 @@ import { runPure, sha256Hex } from '../testing/run-pure';
4
4
  import { buildEntry, buildState } from '../testing/build';
5
5
  import { defaultEnv } from './env';
6
6
  import { queryHashInput } from './hash';
7
- import { ensureRegistered, registerLocal, registerRemote } from './register.saga';
7
+ import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote } from './register.saga';
8
8
  import * as R from '../state/reducers';
9
9
  import { emptyState } from '../state/client-state';
10
10
  import type { StatementResult } from '../kernel/effects';
@@ -212,6 +212,40 @@ describe('registerRemote', () => {
212
212
  expect(last.state.queries.get('a')!.lifecycle.remote).toBe('failed');
213
213
  expect(last.timers.size).toBe(0);
214
214
  });
215
+ it('not allowlisted: 403/Forbidden/not_allowlisted fails after ONE attempt with an error log, no retry timer', async () => {
216
+ for (const text of ['HTTP 403 Forbidden', 'There was an error processing a remote HTTP request: {"error":"not_allowlisted"}', 'status 403']) {
217
+ const out = await runPure(registerRemote(env, 'a'), {
218
+ state: buildState([entry]),
219
+ handlers: { 'remote.query': () => [{ status: 'ERR', error: text }] },
220
+ });
221
+ const e = out.state.queries.get('a')!;
222
+ expect(e.lifecycle).toMatchObject({ remote: 'failed', phase: 'cold', fetchDepth: 0 });
223
+ expect(e.registerAttempts).toBe(0);
224
+ expect(out.timers.size).toBe(0);
225
+ expect(out.emitted).toContainEqual(
226
+ expect.objectContaining({
227
+ type: 'log',
228
+ level: 'error',
229
+ message: 'query refused: not allowlisted',
230
+ data: expect.objectContaining({ hash: 'a', table: entry.def.tableName, surql: entry.def.surql, params: [] }),
231
+ })
232
+ );
233
+ expect(out.dispatched).toContainEqual(expect.objectContaining({ type: 'SyncOutcome', ok: false }));
234
+ }
235
+ const thrown = await runPure(registerRemote(env, 'a'), {
236
+ state: buildState([entry]),
237
+ handlers: {
238
+ 'remote.query': () => {
239
+ throw new Error('Forbidden');
240
+ },
241
+ },
242
+ });
243
+ expect(thrown.state.queries.get('a')!.lifecycle.remote).toBe('failed');
244
+ expect(thrown.timers.size).toBe(0);
245
+ expect(isNotAllowlisted(new Error('socket closed'))).toBe(false);
246
+ expect(isNotAllowlisted('HTTP 4030')).toBe(false);
247
+ });
248
+
215
249
  it('tolerates ERR meta/children statements: edges still apply, missing meta reads as no row', async () => {
216
250
  const out = await runPure(registerRemote(env, 'a'), {
217
251
  state: buildState([entry]),
@@ -128,6 +128,15 @@ export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; a
128
128
 
129
129
  const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
130
130
 
131
+ /**
132
+ * The SSP answers a registration whose shape is not on the query allowlist
133
+ * with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
134
+ * `fn::query::register` statement error carrying SurrealDB's http error text
135
+ * (status reason and/or body), and no retry can change the answer.
136
+ */
137
+ export const isNotAllowlisted = (error: unknown): boolean =>
138
+ /not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
139
+
131
140
  /**
132
141
  * One remote registration: `fn::query::register` plus the edge/meta/children
133
142
  * read in ONE request, then membership application. Concurrent with every
@@ -174,6 +183,23 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
174
183
  } catch (error) {
175
184
  const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
176
185
  if (!current) return;
186
+ if (isNotAllowlisted(error)) {
187
+ yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
188
+ yield fx.emit({
189
+ type: 'log',
190
+ level: 'error',
191
+ message: 'query refused: not allowlisted',
192
+ data: {
193
+ hash,
194
+ table: entry.def.tableName,
195
+ surql: entry.def.surql,
196
+ params: Object.keys(entry.def.params),
197
+ hint: 'Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev).',
198
+ },
199
+ });
200
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
201
+ return;
202
+ }
177
203
  const attempts = current.registerAttempts + 1;
178
204
  yield fx.state.update(R.bumpRegisterAttempts(hash));
179
205
  yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
package/src/types.ts CHANGED
@@ -322,6 +322,15 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
322
322
  * one-shot but never sync live.
323
323
  */
324
324
  enableAnonymousLiveQueries?: boolean;
325
+ /**
326
+ * Opt in to the raw remote escape hatches (`useRemote`, `remoteQuery`,
327
+ * `queryRaw`) when the generated schema's `policy.queryAllowlist` is `warn`
328
+ * or `enforce`. With the allowlist on, the server only accepts query shapes
329
+ * that `spky generate` collected from the query builder, so a hand-written
330
+ * SurrealQL string cannot be allowlisted and these methods throw unless this
331
+ * flag is `true`. Defaults to `false`.
332
+ */
333
+ allowRawRemote?: boolean;
325
334
  /**
326
335
  * Surface sustained sync failures as a "degraded" health status that the app
327
336
  * can observe via `subscribeToSyncHealth` (or the client-solid