@somewhere-tech/cli 0.37.18 → 0.37.20

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@somewhere-tech/cli",
3
- "version": "0.37.18",
3
+ "version": "0.37.20",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@somewhere-tech/cli",
9
- "version": "0.37.18",
9
+ "version": "0.37.20",
10
10
  "bundleDependencies": [
11
11
  "@modelcontextprotocol/sdk",
12
12
  "chokidar",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@somewhere-tech/cli",
3
- "version": "0.37.18",
4
- "description": "CLI for somewhere.tech — auth, projects, deploy, pull, promote, db, logs, env, MCP bridge.",
3
+ "version": "0.37.20",
4
+ "description": "CLI for somewhere.tech \u2014 auth, projects, deploy, pull, promote, db, logs, env, MCP bridge.",
5
5
  "keywords": [
6
6
  "somewhere",
7
7
  "somewhere.tech",
@@ -37,7 +37,7 @@
37
37
  "build": "tsc",
38
38
  "dev": "tsc --watch",
39
39
  "prepublishOnly": "npm run build",
40
- "test": "npm run build && node --test \"test/*.test.mjs\"",
40
+ "test": "npm run build && node --test --test-concurrency=1 \"test/*.test.mjs\"",
41
41
  "test:roundtrip": "node scripts/e2e-roundtrip.mjs",
42
42
  "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\""
43
43
  },
@@ -1,30 +1,31 @@
1
1
  {
2
2
  "esbuild": "0.24.0",
3
3
  "source_files": {
4
- "worker/containers/compile/declared-data-contract.cjs": "bf2d7d56252e1f64ded67c322acf7fa0f2dc722d79f3965211a3442859d26d9f",
4
+ "worker/containers/compile/declared-data-contract.cjs": "abdf0a0f238f9d7ecccc8aa3ede7cd25778194d0b9c68cc6c44aa776de46d122",
5
5
  "worker/containers/compile/groups-types.cjs": "f0f38ca6f160561f997b20af44763bd07232deb2668a536cd52c1974fcfed2c8",
6
- "worker/containers/compile/runtime-types.cjs": "23e1fff1d12bbc75b2554f066e2a037169d24382a42810698b177bd6b60896e2",
7
- "worker/containers/compile/schema-types.cjs": "1c5a0b3ad6b506dc178de7f031080426558cab055f1c1620eb949c524e5546f8",
6
+ "worker/containers/compile/runtime-types.cjs": "7d2e647e6a7301b4158f8d45d5c52da9aac3b6fb6f8118be5a5cde62c40462fc",
7
+ "worker/containers/compile/schema-types.cjs": "97941a4f80f50f4a48b828a6a2695f6f0c606ab6e74b4511fbb99e786dc4f0c4",
8
8
  "worker/containers/compile/typed-data.cjs": "57b458e16a373e42d48a0e527317fcdf609299ae368eb539a8b582b9a47abb15",
9
9
  "worker/containers/compile/typed-files.cjs": "a0b1818afbc7673f84583c76436a3b3ca1d1fdf63d8d8db138b36646103f0708",
10
10
  "worker/src/services/jobs/limits.ts": "f806f045dcb92f7da1e830aab65b74185ebd3071aeade368db776f3a0546de38",
11
- "worker/src/utils/authority/scope-enforcement.ts": "b972c0433c9db1f9f84eb7084a5fd2fe313ff66f86c2ee6556dceac0c8e7683e",
11
+ "worker/src/utils/authority/scope-enforcement.ts": "b219c1b38b4d5ffa7067b0ab9349efec17c1001195a23c323ec40ae6fe4f7da6",
12
12
  "worker/src/utils/db-schema-deploy/bigint-text.ts": "ef3f581daeecb234d2c1e288bc09495a6facd7b0354b300134918f6c0cf10cc2",
13
13
  "worker/src/utils/db-schema-deploy/client-contract-source.ts": "25916e9e5174a3959e5b3f61ee1bc4a3ff3c0de01b155e19be97c836431cb8ec",
14
- "worker/src/utils/db-schema-deploy/client-permissions.ts": "1ec99e9b5e9c575460253f8d4a656228919aca6da09d43065e2b34bcbb633b0c",
15
- "worker/src/utils/db-schema-deploy/extract-schema-app-roles.ts": "2172aa1fb36e248d50c7024dd212ea6292bfd810b2e72a778793247a61ba4487",
14
+ "worker/src/utils/db-schema-deploy/client-permissions.ts": "9c0aecd4a9698a9813aa8ec3315c1a8c99ba2fd1150d6d820a86ea3ec7b9890b",
15
+ "worker/src/utils/db-schema-deploy/extract-schema-app-roles.ts": "7f0e6ce3e213d56b678a60de133d10ed6f51f3a42ba73071d821d42a48c676a2",
16
16
  "worker/src/utils/db-schema-deploy/extract-schema-bindings.ts": "34a2bf98da533afa60cc3fe46c3ff029300f06fef0bfecd98e028bff181ca1b3",
17
17
  "worker/src/utils/db-schema-deploy/extract-schema-files.ts": "dddbb2d914a112e2820598d64f9da781aecec55e121f10de3501c3a7b4238f36",
18
- "worker/src/utils/db-schema-deploy/extract-schema-groups.ts": "ec99b8fbc05dae3f5891924fae006481cf5317db76fe56201a04c85a86f6ddad",
18
+ "worker/src/utils/db-schema-deploy/extract-schema-groups.ts": "f80b93121e1ece04f43fa728517b5baa72fed1d9c833c23cf87ca558f8ca0263",
19
+ "worker/src/utils/db-schema-deploy/extract-schema-links.ts": "91cd99fc90133e51cbbd92656d592714a8fd71e0ac1c869cf161ad4a07225b57",
19
20
  "worker/src/utils/db-schema-deploy/extract-schema-options.ts": "0998748a953fa9df742a7bdcbbce01f4fc56f9735ddbe3adf9e01facd82f87ad",
20
21
  "worker/src/utils/db-schema-deploy/extract-schema-parents.ts": "eaec77c4db2c9a1903bd07fb265fc4f3fa8941f23724ab568ccdbf9569dc7a78",
21
22
  "worker/src/utils/db-schema-deploy/extract-schema-relations.ts": "3984683d3227e964845836079476687a7295e6907b525ef21b9eebc488825bf8",
22
23
  "worker/src/utils/db-schema-deploy/extract-schema-roles.ts": "1b2d7c7279679c726053c5d1586221476587273fc24ce93bde72a4a8ef8e7582",
23
- "worker/src/utils/db-schema-deploy/extract-schema-ts.ts": "7612fe1eebf9431e9a4e30815cf4f8fc5212084324f419e9c13a296213c312fe",
24
+ "worker/src/utils/db-schema-deploy/extract-schema-ts.ts": "af2065c22a1b2c736ca432e0b1855007a99c3239aefe5d0f9bff208d45201cd2",
24
25
  "worker/src/utils/db-schema-deploy/schema-constants.ts": "07e648748409207fa4b97367b8942463c047a0bb0e27a36f16f696f90f2b550d",
25
26
  "worker/src/utils/db-schema-deploy/schema-ownership.ts": "e6c811a87bc657707be3518dfe547c6d97718ac073108b126eb7f791c9c08202",
26
- "worker/src/utils/managed-groups/errors.ts": "2ea002d5bb2890492fbe470e0dabb6681060780e94b89e2c30d37cabe6484383",
27
+ "worker/src/utils/managed-groups/errors.ts": "4830b1ad7357a79760b0bcc68884e562cfbc07d12a37cc73651a6fcb256d682f",
27
28
  "worker/src/utils/managed-groups/policy.ts": "2259a883645c8bfd607ead7c37387f3ffd42ac9dfb86722c72b224adb498511e"
28
29
  },
29
- "sha256": "fa5b1baeb04b1cbbd2cd30c743f9c132df53a519be605b31c50f527f449b314e"
30
+ "sha256": "849e3664a55c1c5fb67faf54b0866db9b1d10b10807dd8a5631893a775a626d8"
30
31
  }
@@ -68,7 +68,7 @@ var require_declared_data_contract = __commonJS({
68
68
  }
69
69
  if (value.k === "a" && keys === "k,p" && Array.isArray(value.p) && value.p.length === 2) {
70
70
  const left = policy(value.p[0], label), right = policy(value.p[1], label);
71
- if (left.k !== "o" || right.k !== "m") fail(label);
71
+ if (left.k !== "o" || !["m", "g", "r"].includes(right.k)) fail(label);
72
72
  return { k: "a", p: [left, right] };
73
73
  }
74
74
  if (value.k === "g" && ["c,k,roles", "c,generation,k,roles"].includes(keys) && identifier.test(value.c)) {
@@ -130,7 +130,7 @@ var require_declared_data_contract = __commonJS({
130
130
  const pk = table.clientPrimaryKey;
131
131
  if (!seen.has(pk) || !["integer", "text"].includes(columns.find((column) => column.n === pk).t)) fail(`${name} primary key`);
132
132
  const input = table.client;
133
- if (Object.keys(input).some((key) => !["read", "publicRead", "create", "update", "delete", "identity"].includes(key))) fail(`${name} permissions`);
133
+ if (Object.keys(input).some((key) => !["read", "publicRead", "create", "update", "delete", "identity", "links"].includes(key))) fail(`${name} permissions`);
134
134
  const read = typeof input.read === "boolean" ? input.read : fields(input.read, seen, `${name}.read`);
135
135
  if (Array.isArray(read) && !read.includes(pk)) fail(`${name}.read must include the primary key`);
136
136
  let publicRead = input.publicRead;
@@ -186,13 +186,31 @@ var require_declared_data_contract = __commonJS({
186
186
  if (normalizedPolicy?.k === "g" && create !== null && !create.includes(normalizedPolicy.c)) fail(`${name}.policy group create`);
187
187
  if (normalizedPolicy?.k === "a") {
188
188
  owner = normalizedPolicy.p[0].c;
189
- if ([...create || [], ...update || []].includes(owner) || (update || []).some((field) => normalizedPolicy.p[1].m.g.includes(field))) fail(`${name}.policy identity writes`);
189
+ const branch = normalizedPolicy.p[1];
190
+ const fixed = branch.k === "m" ? branch.m.g : branch.k === "g" ? [branch.c] : [];
191
+ if ([...create || [], ...update || []].includes(owner) || (update || []).some((field) => fixed.includes(field))) fail(`${name}.policy identity writes`);
192
+ if (branch.k === "g" && create !== null && !create.includes(branch.c)) fail(`${name}.policy group create`);
190
193
  }
191
194
  if (normalizedPolicy?.k === "p" && create !== null && !create.includes(normalizedPolicy.v)) fail(`${name}.policy parent create`);
195
+ let links = null;
196
+ if (input.links !== void 0) {
197
+ record(input.links, `${name}.links`);
198
+ if (Object.keys(input.links).sort().join() !== "children,edit,maxDays,read") fail(`${name}.links`);
199
+ if (!(intent === "scoped" && !visitors) && normalizedPolicy?.k !== "a") fail(`${name}.links owner`);
200
+ const readable = read === true ? columns.filter((column) => column.n !== author).map((column) => column.n) : read || [];
201
+ const linkRead = fields(input.links.read, new Set(readable), `${name}.links.read`);
202
+ if (!linkRead.includes(pk)) fail(`${name}.links.read must include the primary key`);
203
+ const linkEdit = fields(input.links.edit, new Set(update || []), `${name}.links.edit`, true);
204
+ const children = input.links.children;
205
+ if (!Array.isArray(children) || children.some((child) => typeof child !== "string" || !identifier.test(child)) || new Set(children).size !== children.length) fail(`${name}.links.children`);
206
+ const maxDays = input.links.maxDays;
207
+ if (maxDays !== null && !(Number.isSafeInteger(maxDays) && maxDays >= 1 && maxDays <= 365)) fail(`${name}.links.maxDays`);
208
+ links = { read: linkRead, edit: linkEdit, children: [...children].sort(), maxDays };
209
+ }
192
210
  tables.push({
193
211
  name,
194
212
  columns,
195
- client: { identity: input.identity, read, publicRead, create, update, delete: input.delete },
213
+ client: { identity: input.identity, read, publicRead, create, update, delete: input.delete, ...links ? { links } : {} },
196
214
  primaryKey: pk,
197
215
  intent,
198
216
  owner,
@@ -233,6 +251,13 @@ var require_declared_data_contract = __commonJS({
233
251
  }
234
252
  if (relations.length) table.relations = relations.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
235
253
  }
254
+ for (const table of tables) {
255
+ for (const name of table.client.links ? table.client.links.children : []) {
256
+ const relation = (table.relations || []).find((item) => item.name === name);
257
+ const child = relation && tableByName.get(relation.table);
258
+ if (!relation || !child || child.policy?.k !== "p" || child.policy.v !== relation.fk || child.policy.t !== table.name) fail(`${table.name}.links.children`);
259
+ }
260
+ }
236
261
  return JSON.stringify({ version: 1, tables });
237
262
  }
238
263
  function columnType(column) {
@@ -247,11 +272,14 @@ var require_declared_data_contract = __commonJS({
247
272
  });
248
273
  return values.length ? `{ ${values.join("; ")} }` : "Record<string, never>";
249
274
  }
275
+ var LINK_CLIENT_RUNTIME = 'const b64url=s=>btoa(unescape(encodeURIComponent(s))).replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");const createLink=async(table,id,options={})=>{const result=await invoke(table,"link_create",{id,access:options.access,...(options.expiresInDays===undefined?{}:{expires_in_days:options.expiresInDays})});const route=typeof options.returnTo==="string"?options.returnTo:location.pathname+location.search+location.hash;return{link_id:result.link_id,url:location.origin+"/__sw/link#"+result.secret+"."+b64url(route),expires_at:result.expires_at}};const linkDoor=async(path,init)=>{const response=await fetch(path,{credentials:"same-origin",cache:"no-store",...init});const payload=await response.json().catch(()=>({error:"INVALID_DATA_RESPONSE",message:"Link operation returned an invalid response"}));if(!response.ok)throw new DataError(response.status,payload);return payload};const links=Object.freeze({opened:()=>{try{const list=JSON.parse(sessionStorage.getItem("__sw_links_opened")||"[]");return Array.isArray(list)?list:[]}catch(_){return[]}},sessions:async()=>{const payload=await linkDoor("/__sw/link/sessions",{method:"GET"});return{data:payload.data}},forget:async linkId=>{await linkDoor("/__sw/link/forget",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(linkId===undefined?{}:{link_id:linkId})})}});const linkedView=spec=>link=>{if(typeof link!=="string"||!/^lnk_[0-9a-f]{32}$/.test(link))throw new DataError(400,{error:"DATA_INPUT_INVALID",message:"linked() needs a link id"});const view={};for(const[table,canUpdate,children]of spec){const relations={};const root={get:id=>invoke(table,"get",{id,link})};if(canUpdate)root.update=(id,values)=>invoke(table,"update",{id,values,link});for(const[relation,child,canCreate,canUpdateChild]of children){relations[relation]=Object.freeze({list:(parent_id,options={})=>invoke(table,"relation_list",{...options,relation,parent_id,link})});const childOps={};if(canCreate)childOps.create=values=>invoke(child,"create",{values,link});if(canUpdateChild)childOps.update=(id,values)=>invoke(child,"update",{id,values,link});if(canCreate||canUpdateChild)view[child]=Object.freeze(childOps)}if(children.length)root.relations=Object.freeze(relations);view[table]=Object.freeze(root)}return Object.freeze(view)};\n';
250
276
  function describeDataClient2(authority, contract_digest) {
251
277
  const canonical = authority ? canonicalize2(authority.schema, authority.intents, authority.scopes) : canonicalize2({}, {}, {});
252
278
  const { tables } = JSON.parse(canonical);
253
279
  const declarations = [];
254
280
  const runtimeTables = [];
281
+ const linkedDeclarations = [];
282
+ const linkedSpec = [];
255
283
  for (const table of tables) {
256
284
  const { name, columns, client, primaryKey } = table;
257
285
  const readable = client.read === true ? columns.filter((column) => column.n !== table.author).map((column) => column.n) : client.read || [];
@@ -306,9 +334,44 @@ var require_declared_data_contract = __commonJS({
306
334
  relationNames.push(relation.name);
307
335
  }
308
336
  if (relationDeclarations.length) operations.push(`relations: { ${relationDeclarations.join("; ")} }`);
337
+ const links = client.links;
338
+ if (links) {
339
+ operations.push(`createLink(id: ${id}, options: { access: ${links.edit.length ? "'read' | 'edit'" : "'read'"}; expiresInDays?: number; returnTo?: string }): Promise<{ link_id: string; url: string; expires_at: number | null }>`);
340
+ operations.push(`links(id: ${id}): Promise<{ data: Array<{ link_id: string; access: 'read' | 'edit'; created_at: number; expires_at: number | null }> }>`);
341
+ operations.push(`revokeLink(id: ${id}, linkId: string): Promise<{ revoked: boolean }>`);
342
+ methods.push("createLink", "links", "revokeLink");
343
+ const linkRow = shape(columns, links.read, "read");
344
+ const rootOps = [`get(id: ${id}): Promise<{ data: ${linkRow} | null }>`];
345
+ if (links.edit.length) rootOps.push(`update(id: ${id}, values: ${shape(columns, links.edit, "update")}): Promise<{ data: ${linkRow} | null; count: number; changes: number }>`);
346
+ const childRelations = [];
347
+ const childSpecs = [];
348
+ for (const relationName of links.children) {
349
+ const relation = table.relations.find((item) => item.name === relationName);
350
+ const child = tables.find((candidate) => candidate.name === relation.table);
351
+ const childReadable = child.client.read === true ? child.columns.filter((column) => column.n !== child.author).map((column) => column.n) : child.client.read;
352
+ const childRow = shape(child.columns, childReadable, "read");
353
+ const childId = columnType(child.columns.find((column) => column.n === child.primaryKey));
354
+ childRelations.push(`${JSON.stringify(relationName)}: { list(parentId: ${id}, options?: { limit?: number; after?: ${childId} }): Promise<{ data: ${childRow}[]; next: ${childId} | null; has_more: boolean }> }`);
355
+ const childOps = [];
356
+ const canCreate = links.edit.length > 0 && child.client.create !== null;
357
+ const childUpdate = child.client.update ? child.client.update.filter((field) => field !== relation.fk) : [];
358
+ const canUpdate = links.edit.length > 0 && childUpdate.length > 0;
359
+ if (canCreate) childOps.push(`create(values: ${shape(child.columns, child.client.create, "create", /* @__PURE__ */ new Set([relation.fk]))}): Promise<{ data: ${childRow} | null; count: number; changes: number }>`);
360
+ if (canUpdate) childOps.push(`update(id: ${childId}, values: ${shape(child.columns, childUpdate, "update")}): Promise<{ data: ${childRow} | null; count: number; changes: number }>`);
361
+ if (childOps.length) linkedDeclarations.push(`${JSON.stringify(child.name)}: { ${childOps.join("; ")} }`);
362
+ childSpecs.push([relationName, child.name, canCreate, canUpdate]);
363
+ }
364
+ if (childRelations.length) rootOps.push(`relations: { ${childRelations.join("; ")} }`);
365
+ linkedDeclarations.push(`${JSON.stringify(name)}: { ${rootOps.join("; ")} }`);
366
+ linkedSpec.push([name, links.edit.length > 0, childSpecs]);
367
+ }
309
368
  declarations.push(`${JSON.stringify(name)}: { ${operations.join("; ")} }`);
310
369
  runtimeTables.push([name, methods, relationNames]);
311
370
  }
371
+ if (linkedSpec.length) {
372
+ declarations.push(`links: { opened(): Array<{ link_id: string; table: string; row_id: string; access: 'read' | 'edit' }>; sessions(): Promise<{ data: Array<{ link_id: string; table: string; row_id: string; access: 'read' | 'edit'; expires_at: number }> }>; forget(linkId?: string): Promise<void> }`);
373
+ declarations.push(`linked(linkId: string): { ${linkedDeclarations.join("; ")} }`);
374
+ }
312
375
  const declaration = `declare module ${JSON.stringify("somewhere:data")} {
313
376
  export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
314
377
  export class DataError extends Error { readonly status: number; readonly code: string | null; constructor(status: number, payload: unknown); }
@@ -318,7 +381,7 @@ var require_declared_data_contract = __commonJS({
318
381
  const runtime = `const contract=${JSON.stringify(contract_digest)};
319
382
  class DataError extends Error{constructor(status,payload){super(payload&&typeof payload.message==="string"?payload.message:payload&&typeof payload.error==="string"?payload.error:"Data operation failed");this.name="DataError";this.status=status;this.code=payload&&typeof payload.error==="string"?payload.error:null}}
320
383
  const invoke=async(table,operation,input)=>{const response=await fetch("/__sw/data",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({...input,contract,table,operation})});const payload=await response.json().catch(()=>{throw new DataError(response.status,{error:"INVALID_DATA_RESPONSE",message:"Data operation returned an invalid response"})});if(!response.ok)throw new DataError(response.status,payload);return payload};
321
- const data=Object.freeze(Object.fromEntries(${JSON.stringify(runtimeTables)}.map(([table,operations,relations])=>{const entries=operations.map(operation=>[operation,operation==="list"||operation==="aggregate"?(options={})=>invoke(table,operation,options):operation==="create"?values=>invoke(table,operation,{values}):operation==="update"?(id,values)=>invoke(table,operation,{id,values}):id=>invoke(table,operation,{id})]);if(relations.length)entries.push(["relations",Object.freeze(Object.fromEntries(relations.map(relation=>[relation,Object.freeze({list:(parent_id,options={})=>invoke(table,"relation_list",{...options,relation,parent_id})})])))]);return[table,Object.freeze(Object.fromEntries(entries))];})));
384
+ ${linkedSpec.length ? LINK_CLIENT_RUNTIME : ""}const data=Object.freeze(Object.fromEntries(${JSON.stringify(runtimeTables)}.map(([table,operations,relations])=>{const entries=operations.map(operation=>[operation,operation==="list"||operation==="aggregate"?(options={})=>invoke(table,operation,options):operation==="create"?values=>invoke(table,operation,{values}):operation==="update"?(id,values)=>invoke(table,operation,{id,values}):operation==="createLink"?(id,options)=>createLink(table,id,options):operation==="links"?id=>invoke(table,"link_list",{id}):operation==="revokeLink"?(id,link_id)=>invoke(table,"link_revoke",{id,link_id}):id=>invoke(table,operation,{id})]);if(relations.length)entries.push(["relations",Object.freeze(Object.fromEntries(relations.map(relation=>[relation,Object.freeze({list:(parent_id,options={})=>invoke(table,"relation_list",{...options,relation,parent_id})})])))]);return[table,Object.freeze(Object.fromEntries(entries))];}).concat(${linkedSpec.length ? `[["links",links],["linked",linkedView(${JSON.stringify(linkedSpec)})]]` : "[]"})));
322
385
  export{data,DataError};
323
386
  `;
324
387
  return { contract_digest, declaration, runtime, manifest: { version: 1, contract_digest, tables, declaration } };
@@ -993,6 +1056,15 @@ interface SomewhereAuthRequestOptions {
993
1056
  interface SomewhereAuthRequireOptions extends SomewhereAuthRequestOptions { role?: 'admin' }
994
1057
  interface SomewhereAuthAnonSession { id: string; isAnon: true; expiresAt: number }
995
1058
  interface SomewhereAuthModerationRevoked { revoked_sessions: number; revoked_refresh_tokens: number }
1059
+ // The app's own users under explicit server authority (deployed production
1060
+ // functions; not row-bound jobs). Check the caller first: this is not a
1061
+ // browser user list. ids: exact lookup (max 200, no limit/cursor); otherwise
1062
+ // pages ordered by (created_at, id), limit 1-200 (default 50). Banned and
1063
+ // agent accounts are not listed; absent ids are omitted.
1064
+ interface SomewhereAuthDirectoryUser { id: string; email: string; display_name: string | null; email_verified: boolean; created_at: number | null }
1065
+ interface SomewhereRuntimeAuthServer {
1066
+ listUsers(options?: { ids: readonly string[]; limit?: never; cursor?: never } | { ids?: never; limit?: number; cursor?: string }): Promise<{ users: SomewhereAuthDirectoryUser[]; next_cursor: string | null }>;
1067
+ }
996
1068
  interface SomewhereRuntimeAuthModeration {
997
1069
  ban(req: Request, userId: string, options?: { reason?: string | null } | null): Promise<SomewhereAuthModerationRevoked & {
998
1070
  user: { id: string; email: string; banned: true; banned_reason: string | null };
@@ -1067,13 +1139,21 @@ interface SomewhereRuntimeAuth {
1067
1139
  // Password change and account deletion end every session of the account; these clear the cookies.
1068
1140
  updatePasswordWithCookie(req: Request, options: { new_password: string; current_password?: string }): Promise<{ updated: true }>;
1069
1141
  deleteUserWithCookie(req: Request): Promise<{ deleted: true }>;
1070
- googleUrl(options: { redirect_uri: string }): string;
1071
- githubUrl(options: { redirect_uri: string }): string;
1072
- discordUrl(options: { redirect_uri: string }): string;
1073
- googleExchange(options: { code: string; redirect_uri?: string }): Promise<SomewhereAuthGoogleSession>;
1074
- githubExchange(options: { code: string }): Promise<SomewhereAuthOAuthSession>;
1075
- discordExchange(options: { code: string }): Promise<SomewhereAuthOAuthSession>;
1076
- // Reads ?code, exchanges it, stages the session cookies, returns a 302 to redirectTo (default '/').
1142
+ // Start Google/GitHub/Discord sign-in from a server handler: stages this attempt's HttpOnly
1143
+ // verifier cookie on the response and resolves the provider start URL (only its challenge travels).
1144
+ oauthStart(provider: 'google' | 'github' | 'discord', options: { redirect_uri: string }): Promise<string>;
1145
+ /** @deprecated Always throws OAUTH_START_REQUIRES_BINDING: sign-in is bound to the browser that starts it. Use: Response.redirect(await sw.auth.oauthStart('google', { redirect_uri }), 302). The parameter stays assignable so apps on older SDK types still typecheck. */
1146
+ googleUrl(options: { redirect_uri: string }): never;
1147
+ /** @deprecated Always throws OAUTH_START_REQUIRES_BINDING: sign-in is bound to the browser that starts it. Use: Response.redirect(await sw.auth.oauthStart('github', { redirect_uri }), 302). The parameter stays assignable so apps on older SDK types still typecheck. */
1148
+ githubUrl(options: { redirect_uri: string }): never;
1149
+ /** @deprecated Always throws OAUTH_START_REQUIRES_BINDING: sign-in is bound to the browser that starts it. Use: Response.redirect(await sw.auth.oauthStart('discord', { redirect_uri }), 302). The parameter stays assignable so apps on older SDK types still typecheck. */
1150
+ discordUrl(options: { redirect_uri: string }): never;
1151
+ // Exchanges take the callback's ?code= and its public ?attempt= (which selects this browser's attempt cookie).
1152
+ googleExchange(options: { code: string; attempt: string; redirect_uri?: string }): Promise<SomewhereAuthGoogleSession>;
1153
+ githubExchange(options: { code: string; attempt: string }): Promise<SomewhereAuthOAuthSession>;
1154
+ discordExchange(options: { code: string; attempt: string }): Promise<SomewhereAuthOAuthSession>;
1155
+ // Reads ?code and ?attempt, exchanges with that attempt's oauthStart verifier from this request,
1156
+ // stages the session cookies, clears that attempt, returns a 302 to redirectTo (default '/').
1077
1157
  googleCallbackWithCookie(req: Request, redirectTo?: string): Promise<Response>;
1078
1158
  githubCallbackWithCookie(req: Request, redirectTo?: string): Promise<Response>;
1079
1159
  discordCallbackWithCookie(req: Request, redirectTo?: string): Promise<Response>;
@@ -1094,6 +1174,7 @@ interface SomewhereRuntimeAuth {
1094
1174
  getAcceptedInvite(req: Request, id: string): Promise<SomewhereAuthInvite>;
1095
1175
  anonSession(): Promise<SomewhereAuthAnonSession>;
1096
1176
  readonly moderation: SomewhereRuntimeAuthModeration;
1177
+ readonly server: SomewhereRuntimeAuthServer;
1097
1178
  }
1098
1179
  interface SomewhereRuntimeCrypto {
1099
1180
  // Lowercase hex HMAC-SHA256 of message keyed by secret.
@@ -2266,8 +2347,45 @@ interface SomewhereAiModerationResult {
2266
2347
  model: string;
2267
2348
  provider: string;
2268
2349
  }
2350
+ // sw.ai.evaluate: typed decisions. Probabilities are model output, not
2351
+ // calibrated guarantees; choose thresholds from labelled cases.
2352
+ type SomewhereAiEvaluateQuestion =
2353
+ | { type: 'noul'; instructions: string | SomewhereJsonObject | readonly unknown[]; criteria?: { true?: unknown; false?: unknown } }
2354
+ | { type: 'choice'; instructions: string | SomewhereJsonObject | readonly unknown[]; criteria: Record<string, unknown> }
2355
+ | { type: 'score'; instructions: string | SomewhereJsonObject | readonly unknown[]; criteria: readonly unknown[] };
2356
+ type SomewhereAiEvaluateImage =
2357
+ | string
2358
+ | { content_type: 'image/png' | 'image/jpeg' | 'image/webp'; base64: string };
2359
+ type SomewhereAiEvaluateOptions<Q extends Record<string, SomewhereAiEvaluateQuestion>> = {
2360
+ state: string | SomewhereJsonObject | readonly unknown[];
2361
+ questions: Q;
2362
+ } & (
2363
+ // Text routes to jev and images to clef-flash when model is omitted; jev reads text only.
2364
+ | { model: 'jev'; images?: never }
2365
+ | { model?: 'clef-flash'; images?: readonly SomewhereAiEvaluateImage[] }
2366
+ );
2367
+ interface SomewhereAiEvaluateNoulAnswer { type: 'noul'; noul: number }
2368
+ interface SomewhereAiEvaluateChoiceAnswer<O extends string = string> {
2369
+ type: 'choice'; choice: O; probabilities: Record<O, number>; confidence: number;
2370
+ }
2371
+ interface SomewhereAiEvaluateScoreAnswer {
2372
+ type: 'score'; score: number; legend: Record<string, unknown>; probabilities: Record<string, number>; confidence: number;
2373
+ }
2374
+ type SomewhereAiEvaluateAnswer<Q> =
2375
+ Q extends { type: 'noul' } ? SomewhereAiEvaluateNoulAnswer
2376
+ : Q extends { type: 'choice'; criteria: infer C } ? SomewhereAiEvaluateChoiceAnswer<Extract<keyof C, string>>
2377
+ : Q extends { type: 'score' } ? SomewhereAiEvaluateScoreAnswer
2378
+ : never;
2379
+ interface SomewhereAiEvaluateResult<Q extends Record<string, SomewhereAiEvaluateQuestion>> {
2380
+ model: 'clef-flash' | 'jev';
2381
+ provider_model: string;
2382
+ routed_by: 'model' | 'images' | 'text';
2383
+ answers: { [K in keyof Q]: SomewhereAiEvaluateAnswer<Q[K]> };
2384
+ usage: { input_tokens: number };
2385
+ cost: SomewhereAiCost;
2386
+ }
2269
2387
  interface SomewhereAiCatalogEntry {
2270
- feature: 'transcribe' | 'tts' | 'generate_image' | 'remove_background' | 'embed' | 'complete';
2388
+ feature: 'transcribe' | 'tts' | 'generate_image' | 'remove_background' | 'embed' | 'complete' | 'evaluate';
2271
2389
  model: string;
2272
2390
  provider: string;
2273
2391
  label: string;
@@ -2305,6 +2423,7 @@ interface SomewhereRuntimeAi {
2305
2423
  removeBackground(options: SomewhereAiRemoveBackgroundOptions): Promise<never>;
2306
2424
  embeddings(options: SomewhereAiEmbeddingsOptions): Promise<SomewhereAiEmbeddingsResult>;
2307
2425
  moderate(text: string): Promise<SomewhereAiModerationResult>;
2426
+ evaluate<const Q extends Record<string, SomewhereAiEvaluateQuestion>>(options: SomewhereAiEvaluateOptions<Q>): Promise<SomewhereAiEvaluateResult<Q>>;
2308
2427
  catalog(): Promise<SomewhereAiCatalog>;
2309
2428
  }
2310
2429
 
@@ -3436,6 +3555,8 @@ var require_schema_types = __commonJS({
3436
3555
  interface Scope { readonly __somewhereScope: unique symbol }
3437
3556
  interface OwnerScope extends Scope { readonly __somewhereOwner: unique symbol }
3438
3557
  interface MemberScope extends Scope { readonly __somewhereMember: unique symbol }
3558
+ interface GroupScope extends Scope { readonly __somewhereGroup: unique symbol }
3559
+ interface AppRoleScope extends Scope { readonly __somewhereAppRole: unique symbol }
3439
3560
  interface ColumnOptions<Default> {
3440
3561
  nullable?: boolean; default?: Default | null; unique?: boolean;
3441
3562
  references?: string; onDelete?: 'cascade' | 'restrict'; renamedFrom?: string;
@@ -3443,6 +3564,10 @@ var require_schema_types = __commonJS({
3443
3564
  interface ClientPermissions<Field extends string> {
3444
3565
  identity?: 'authenticated' | 'visitor'; read?: boolean | readonly Field[]; publicRead?: boolean | { where: Partial<Record<Field, string | number | boolean | null>> };
3445
3566
  create?: readonly Field[] | false | null; update?: readonly Field[] | false | null; delete?: boolean;
3567
+ links?: ClientLinkPermissions<Field>;
3568
+ }
3569
+ interface ClientLinkPermissions<Field extends string> {
3570
+ read: true | readonly Field[]; edit?: readonly Field[] | false; children?: readonly string[]; maxDays?: number;
3446
3571
  }
3447
3572
  interface FileCollection { readonly __somewhereFileCollection: unique symbol }
3448
3573
  type FileOperation = 'read' | 'upload' | 'replace' | 'delete';
@@ -3486,11 +3611,11 @@ function removed(): SomewhereSchemaDeclaration.ColumnMarker;
3486
3611
  function removedTable(): SomewhereSchemaDeclaration.TableMarker;
3487
3612
  function exported(): SomewhereSchemaDeclaration.TableMarker;
3488
3613
  `;
3489
- var policySignatures = `function anyOf(owner: SomewhereSchemaDeclaration.OwnerScope, member: SomewhereSchemaDeclaration.MemberScope): SomewhereSchemaDeclaration.Scope;
3490
- function anyOf(member: SomewhereSchemaDeclaration.MemberScope, owner: SomewhereSchemaDeclaration.OwnerScope): SomewhereSchemaDeclaration.Scope;
3614
+ var policySignatures = `function anyOf(owner: SomewhereSchemaDeclaration.OwnerScope, other: SomewhereSchemaDeclaration.MemberScope | SomewhereSchemaDeclaration.GroupScope | SomewhereSchemaDeclaration.AppRoleScope): SomewhereSchemaDeclaration.Scope;
3615
+ function anyOf(other: SomewhereSchemaDeclaration.MemberScope | SomewhereSchemaDeclaration.GroupScope | SomewhereSchemaDeclaration.AppRoleScope, owner: SomewhereSchemaDeclaration.OwnerScope): SomewhereSchemaDeclaration.Scope;
3491
3616
  function parent(options: { via: string; table?: string }): SomewhereSchemaDeclaration.Scope;
3492
- function group(options?: { column?: string; roles?: Partial<Record<'read' | 'create' | 'update' | 'delete', string[]>> }): SomewhereSchemaDeclaration.Scope;
3493
- function appRole(options: { roles: Partial<Record<'read' | 'create' | 'update' | 'delete', string[]>> }): SomewhereSchemaDeclaration.Scope;
3617
+ function group(options?: { column?: string; roles?: Partial<Record<'read' | 'create' | 'update' | 'delete', string[]>> }): SomewhereSchemaDeclaration.GroupScope;
3618
+ function appRole(options: { roles: Partial<Record<'read' | 'create' | 'update' | 'delete', string[]>> }): SomewhereSchemaDeclaration.AppRoleScope;
3494
3619
  `;
3495
3620
  var SCHEMA_DECLARATION2 = types + signatures.replace(/^function /gm, "declare function ") + `declare module 'somewhere/db' {
3496
3621
  ${(signatures + policySignatures).replace(/^function /gm, " export function ")}}
@@ -3533,7 +3658,7 @@ function parseClientPermissions(value, columns, scope) {
3533
3658
  }
3534
3659
  const input = value;
3535
3660
  for (const key of Object.keys(input)) {
3536
- if (!["identity", "read", "publicRead", "create", "update", "delete"].includes(key)) {
3661
+ if (!["identity", "read", "publicRead", "create", "update", "delete", "links"].includes(key)) {
3537
3662
  return fail(`Unknown client permission "${key}".`);
3538
3663
  }
3539
3664
  }
@@ -3639,7 +3764,77 @@ function parseClientPermissions(value, columns, scope) {
3639
3764
  }
3640
3765
  }
3641
3766
  }
3642
- return { ok: true, permissions: { identity, read, publicRead, ...writes, delete: remove } };
3767
+ let links;
3768
+ if (input.links !== void 0) {
3769
+ const parsed = parseLinkPermissions(input.links, read, writes.update, columns, scope);
3770
+ if (typeof parsed === "string") return fail(parsed);
3771
+ links = parsed;
3772
+ }
3773
+ return { ok: true, permissions: { identity, read, publicRead, ...writes, delete: remove, ...links ? { links } : {} } };
3774
+ }
3775
+ function authoredClientFromCanonical(client) {
3776
+ if (!client || typeof client !== "object" || Array.isArray(client)) return client;
3777
+ const links = client.links;
3778
+ if (!links || typeof links !== "object" || Array.isArray(links)) return client;
3779
+ const l = links;
3780
+ const keys = Object.keys(l).sort().join(",");
3781
+ if (keys !== "children,edit,maxDays,read" || !Array.isArray(l.read) || !Array.isArray(l.edit) || !Array.isArray(l.children) || !(l.maxDays === null || typeof l.maxDays === "number")) return client;
3782
+ const authored = { read: l.read, edit: l.edit.length === 0 ? false : l.edit, children: l.children };
3783
+ if (l.maxDays !== null) authored.maxDays = l.maxDays;
3784
+ return { ...client, links: authored };
3785
+ }
3786
+ function parseLinkPermissions(value, read, update, columns, scope) {
3787
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "client.links must be an object: { read, edit?, children?, maxDays? }.";
3788
+ const input = value;
3789
+ for (const key2 of Object.keys(input)) {
3790
+ if (!["read", "edit", "children", "maxDays"].includes(key2)) return `Unknown client.links option "${key2}". Allowed: read, edit, children, maxDays.`;
3791
+ }
3792
+ const owned = scope.kind === "owner" && scope.visitors !== true || scope.kind === "policy" && typeof scope.column === "string";
3793
+ if (!owned) return "client.links needs rows with one signed-in owner: owner() (without visitors) or anyOf(owner(), ...).";
3794
+ if (read === false) return "client.links needs client.read: a link can only read fields the app data client reads.";
3795
+ const readable = read === true ? columns.filter((column) => column.helper !== "blob").map((column) => column.name) : read;
3796
+ const list = (raw, label, allowed, allowedLabel) => {
3797
+ if (!Array.isArray(raw)) return `client.links.${label} must list field names.`;
3798
+ const names = [];
3799
+ for (const entry of raw) {
3800
+ if (typeof entry !== "string") return `client.links.${label} fields must be column names.`;
3801
+ const name = entry.toLowerCase();
3802
+ if (!allowed.includes(name)) return `client.links.${label} names "${entry}", which is not in ${allowedLabel}.`;
3803
+ if (names.includes(name)) return `client.links.${label} declares field "${entry}" twice.`;
3804
+ names.push(name);
3805
+ }
3806
+ return names.sort();
3807
+ };
3808
+ const linkRead = input.read === true ? [...readable].sort() : list(input.read, "read", readable, "client.read");
3809
+ if (typeof linkRead === "string") return linkRead;
3810
+ if (linkRead.length === 0) return "client.links.read must name at least one field, or be true.";
3811
+ const key = columns.find((column) => column.helper === "id")?.name;
3812
+ if (!key || !linkRead.includes(key)) return "client.links.read must include the primary key.";
3813
+ let edit = [];
3814
+ if (input.edit !== void 0 && input.edit !== false) {
3815
+ if (!update) return "client.links.edit needs client.update: a link can only update fields the app data client updates.";
3816
+ const parsed = list(input.edit, "edit", update, "client.update");
3817
+ if (typeof parsed === "string") return parsed;
3818
+ if (parsed.length === 0) return "client.links.edit must name at least one field, or be false.";
3819
+ if (scope.column && parsed.includes(scope.column)) return `client.links.edit cannot write the owner field "${scope.column}".`;
3820
+ edit = parsed;
3821
+ }
3822
+ let children = [];
3823
+ if (input.children !== void 0) {
3824
+ if (!Array.isArray(input.children) || input.children.some((entry) => typeof entry !== "string" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(entry))) {
3825
+ return "client.links.children must list relation names declared on this table.";
3826
+ }
3827
+ children = [...new Set(input.children.map((entry) => entry.toLowerCase()))].sort();
3828
+ if (children.length !== input.children.length) return "client.links.children names a relation twice.";
3829
+ }
3830
+ let maxDays = null;
3831
+ if (input.maxDays !== void 0) {
3832
+ if (typeof input.maxDays !== "number" || !Number.isSafeInteger(input.maxDays) || input.maxDays < 1 || input.maxDays > 365) {
3833
+ return "client.links.maxDays must be a whole number of days from 1 to 365.";
3834
+ }
3835
+ maxDays = input.maxDays;
3836
+ }
3837
+ return { read: linkRead, edit, children, maxDays };
3643
3838
  }
3644
3839
 
3645
3840
  // worker/src/services/jobs/limits.ts
@@ -3928,7 +4123,7 @@ function bakedTableSchemaFromDeclared(declaredJson) {
3928
4123
  const sc = scope;
3929
4124
  if (typeof sc.kind !== "string") return null;
3930
4125
  const policyIdentity = sc.kind === "policy" ? declaredPolicyIdentity(sc.policy) : {};
3931
- const parsed = parseClientPermissions(shape.client, permissionColumns, {
4126
+ const parsed = parseClientPermissions(authoredClientFromCanonical(shape.client), permissionColumns, {
3932
4127
  kind: sc.kind,
3933
4128
  ...visitors ? { visitors: true } : {},
3934
4129
  ...typeof sc.column === "string" ? { column: sc.column } : {},
@@ -3959,7 +4154,11 @@ function declaredPolicyIdentity(value) {
3959
4154
  if (node.kind === "owner" && typeof node.column === "string") return { column: node.column };
3960
4155
  if (node.kind === "member" && Array.isArray(node.group) && node.group.every((entry) => typeof entry === "string")) return { group: node.group };
3961
4156
  if (node.kind === "any_of" && Array.isArray(node.policies)) {
3962
- return node.policies.reduce((all, child) => ({ ...all, ...declaredPolicyIdentity(child) }), {});
4157
+ const branchGroup = (child) => {
4158
+ const c = child;
4159
+ return c && c.kind === "group" && typeof c.column === "string" ? { group: [c.column] } : {};
4160
+ };
4161
+ return node.policies.reduce((all, child) => ({ ...all, ...declaredPolicyIdentity(child), ...branchGroup(child) }), {});
3963
4162
  }
3964
4163
  return {};
3965
4164
  }
@@ -3974,9 +4173,10 @@ function bakedPolicyFromDeclared(value, depth = 0, edges = 0, underParent = fals
3974
4173
  return member ? { k: "m", m: member } : null;
3975
4174
  }
3976
4175
  if (node.kind === "any_of" && Array.isArray(node.policies) && node.policies.length === 2) {
3977
- if (depth !== 0 && !underParent || node.policies[0]?.kind !== "owner" || node.policies[1]?.kind !== "member") return null;
4176
+ const second = node.policies[1]?.kind;
4177
+ if (depth !== 0 && !underParent || node.policies[0]?.kind !== "owner" || second !== "member" && second !== "group" && second !== "app_role") return null;
3978
4178
  const left = bakedPolicyFromDeclared(node.policies[0], depth + 1);
3979
- const right = bakedPolicyFromDeclared(node.policies[1], depth + 1);
4179
+ const right = bakedPolicyFromDeclared(node.policies[1], depth + 1, edges, second !== "member");
3980
4180
  return left && right ? { k: "a", p: [left, right] } : null;
3981
4181
  }
3982
4182
  if (node.kind === "group") {
@@ -4414,6 +4614,43 @@ function validateDeclaredRelations(tables, tableByName, knownColumns, markedGone
4414
4614
  }
4415
4615
  }
4416
4616
 
4617
+ // worker/src/utils/db-schema-deploy/extract-schema-links.ts
4618
+ function validateRowLinks(tables, tableByName, errors) {
4619
+ const childOf = /* @__PURE__ */ new Map();
4620
+ for (const t of tables) {
4621
+ const links = t.client?.links;
4622
+ if (!links) continue;
4623
+ for (const name of links.children) {
4624
+ const relation = t.relations.find((r) => r.name === name);
4625
+ const child = relation ? tableByName.get(relation.table) : void 0;
4626
+ if (!relation || relation.kind !== "hasMany" || !child) {
4627
+ errors.push(`Table "${t.name}" lists "${name}" in client.links.children, which is not a hasMany relation declared on "${t.name}".`);
4628
+ continue;
4629
+ }
4630
+ const scope = child.scope;
4631
+ if (scope.kind !== "policy" || scope.policy.kind !== "parent" || scope.policy.table !== t.name || scope.policy.via !== relation.fk) {
4632
+ errors.push(`Table "${t.name}" lists "${name}" in client.links.children, but "${child.name}" is not scoped parent({ via: '${relation.fk}' }) on "${t.name}". A link reaches only rows that belong to the linked row.`);
4633
+ continue;
4634
+ }
4635
+ if (!child.client || child.client.read === false) {
4636
+ errors.push(`Table "${t.name}" lists "${name}" in client.links.children, but "${child.name}" declares no client.read.`);
4637
+ continue;
4638
+ }
4639
+ const earlier = childOf.get(child.name);
4640
+ if (earlier && earlier !== t.name) {
4641
+ errors.push(`Table "${child.name}" is a link child of both "${earlier}" and "${t.name}". Declare it as a link child of one table.`);
4642
+ continue;
4643
+ }
4644
+ childOf.set(child.name, t.name);
4645
+ }
4646
+ }
4647
+ for (const [child, parent] of childOf) {
4648
+ if (tableByName.get(child)?.client?.links) {
4649
+ errors.push(`Table "${child}" declares client.links and is also a link child of "${parent}". Links reach one level of children.`);
4650
+ }
4651
+ }
4652
+ }
4653
+
4417
4654
  // worker/src/utils/db-schema-deploy/extract-schema-files.ts
4418
4655
  var FILE_OPERATIONS = ["read", "upload", "replace", "delete"];
4419
4656
  var MAX_FILE_COLLECTIONS = 20;
@@ -4869,10 +5106,13 @@ var sorted = (list) => [...list].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
4869
5106
  function resolveGroupScopes(tables, vocabulary, errors) {
4870
5107
  const allRoles = [...BUILT_IN_GROUP_ROLES2, ...vocabulary?.roles ?? []];
4871
5108
  for (const t of tables) {
4872
- if (t.scope.kind !== "policy" || t.scope.policy.kind !== "group") continue;
4873
- const node = t.scope.policy;
5109
+ if (t.scope.kind !== "policy") continue;
5110
+ const branch = t.scope.policy.kind === "any_of" ? t.scope.policy.policies.find((p) => p.kind === "group") : void 0;
5111
+ const node = t.scope.policy.kind === "group" ? t.scope.policy : branch?.kind === "group" ? branch : null;
5112
+ if (!node) continue;
4874
5113
  const declared = declaredGroupRoles.get(node);
4875
5114
  if (!declared) continue;
5115
+ const omitted = branch ? [] : allRoles;
4876
5116
  const where = `line ${declared.line}: `;
4877
5117
  const column = t.columns.find((c) => c.name === node.column);
4878
5118
  if (!column) {
@@ -4881,7 +5121,7 @@ function resolveGroupScopes(tables, vocabulary, errors) {
4881
5121
  errors.push(`${where}Table "${t.name}" uses group({ column: '${node.column}' }), which must be a non-nullable text() column: every row belongs to exactly one group.`);
4882
5122
  }
4883
5123
  for (const op of GROUP_OPERATIONS) {
4884
- const list = declared.explicit[op] ?? allRoles;
5124
+ const list = declared.explicit[op] ?? omitted;
4885
5125
  for (const role of list) {
4886
5126
  if (!allRoles.includes(role)) {
4887
5127
  errors.push(`${where}group({ roles: { ${op} } }) on table "${t.name}" names "${role}", which is not a group role. Use owner, admin, member, or a role declared in groups.roles.`);
@@ -4895,10 +5135,12 @@ function resolveGroupScopes(tables, vocabulary, errors) {
4895
5135
  errors.push(`${where}group() on table "${t.name}" lets ${missing.join(", ")} ${op} rows they cannot read. A role that may ${op} must also read, because the write returns the row.`);
4896
5136
  }
4897
5137
  }
5138
+ if (branch && GROUP_OPERATIONS.every((op) => node.roles[op].length === 0)) {
5139
+ errors.push(`${where}group() inside anyOf() on table "${t.name}" grants no operation. List the group roles that may read, update or delete every row of the group.`);
5140
+ }
4898
5141
  if (t.client?.create && !t.client.create.includes(node.column)) {
4899
5142
  errors.push(`${where}Table "${t.name}" uses group() and client.create must include "${node.column}" so every new row names the group it belongs to.`);
4900
5143
  }
4901
- declaredGroupRoles.delete(node);
4902
5144
  }
4903
5145
  }
4904
5146
  function groupLeafOf(t) {
@@ -5001,8 +5243,10 @@ function readAppRoleScope(r, tableName, line, fail) {
5001
5243
  function resolveAppRoleScopes(tables, appRoles, errors) {
5002
5244
  const vocabulary = new Set((appRoles ?? []).map((role) => role.name));
5003
5245
  for (const t of tables) {
5004
- if (t.scope.kind !== "policy" || t.scope.policy.kind !== "app_role") continue;
5005
- const node = t.scope.policy;
5246
+ if (t.scope.kind !== "policy") continue;
5247
+ const root = t.scope.policy;
5248
+ const node = root.kind === "app_role" ? root : root.kind === "any_of" ? root.policies.find((p) => p.kind === "app_role") : void 0;
5249
+ if (!node) continue;
5006
5250
  const declared = declaredLines.get(node);
5007
5251
  if (!declared) continue;
5008
5252
  for (const { op, name, line } of declared.names) {
@@ -5016,6 +5260,9 @@ function resolveAppRoleScopes(tables, appRoles, errors) {
5016
5260
  errors.push(`line ${declared.line}: appRole() on table "${t.name}" lets ${missing.join(", ")} ${op} rows they cannot read. A role that may ${op} must also read, because the write returns the row.`);
5017
5261
  }
5018
5262
  }
5263
+ if (root.kind === "any_of" && APP_ROLE_OPERATIONS.every((op) => node.roles[op].length === 0)) {
5264
+ errors.push(`line ${declared.line}: appRole() inside anyOf() on table "${t.name}" grants no operation. List the app roles that may read, update or delete every row.`);
5265
+ }
5019
5266
  declaredLines.delete(node);
5020
5267
  }
5021
5268
  }
@@ -5412,7 +5659,7 @@ function readClientPermissions(r) {
5412
5659
  }
5413
5660
  out[key.name] = fields;
5414
5661
  } else {
5415
- out[key.name] = r.readLiteral(`for client.${key.name}`);
5662
+ out[key.name] = key.name === "links" && r.peek().kind === "punct" && r.peek().value === "{" ? readClientPermissions(r) : r.readLiteral(`for client.${key.name}`);
5416
5663
  }
5417
5664
  if (!r.tryPunct(",")) {
5418
5665
  r.expectPunct("}", "closing client permissions");
@@ -5713,23 +5960,30 @@ function readPolicyMember(r, tableName, line) {
5713
5960
  if (scope.kind !== "member") throw new SchemaTsError(`line ${line}: invalid member() policy.`);
5714
5961
  return scope;
5715
5962
  }
5963
+ var ANY_OF_FORMS = "anyOf() accepts exactly one owner() and one member(), group() or appRole().";
5964
+ function readAnyOfBranch(r, tableName, which) {
5965
+ const tok = r.expectIdent(`for the ${which} policy in anyOf() on table "${tableName}"`);
5966
+ const failAt = (at, message) => {
5967
+ throw new SchemaTsError(`line ${at}: ${message}`);
5968
+ };
5969
+ if (tok.value === "owner") return readPolicyOwner(r, tableName, tok.line);
5970
+ if (tok.value === "member") return readPolicyMember(r, tableName, tok.line);
5971
+ if (tok.value === "group") return readGroupScope(r, tableName, tok.line, failAt);
5972
+ if (tok.value === "appRole") return readAppRoleScope(r, tableName, tok.line, failAt);
5973
+ throw new SchemaTsError(`line ${tok.line}: ${ANY_OF_FORMS}`);
5974
+ }
5716
5975
  function readAnyOfScope(r, tableName, line) {
5717
5976
  r.expectPunct("(", 'after "anyOf"');
5718
- const first = r.expectIdent(`for the first policy in anyOf() on table "${tableName}"`);
5719
- const firstPolicy = first.value === "owner" ? readPolicyOwner(r, tableName, first.line) : first.value === "member" ? readPolicyMember(r, tableName, first.line) : null;
5720
- if (!firstPolicy) throw new SchemaTsError(`line ${first.line}: anyOf() only accepts owner() and member().`);
5977
+ const firstPolicy = readAnyOfBranch(r, tableName, "first");
5721
5978
  r.expectPunct(",", `between the two policies in anyOf() on table "${tableName}"`);
5722
- const second = r.expectIdent(`for the second policy in anyOf() on table "${tableName}"`);
5723
- const secondPolicy = second.value === "owner" ? readPolicyOwner(r, tableName, second.line) : second.value === "member" ? readPolicyMember(r, tableName, second.line) : null;
5724
- if (!secondPolicy) throw new SchemaTsError(`line ${second.line}: anyOf() only accepts owner() and member().`);
5979
+ const secondPolicy = readAnyOfBranch(r, tableName, "second");
5725
5980
  r.tryPunct(",");
5726
5981
  r.expectPunct(")", "closing anyOf()");
5727
- if (firstPolicy.kind === secondPolicy.kind) {
5728
- throw new SchemaTsError(`line ${line}: anyOf() requires exactly one owner() and one member() policy.`);
5729
- }
5730
- const owner = firstPolicy.kind === "owner" ? firstPolicy : secondPolicy;
5731
- const member = firstPolicy.kind === "member" ? firstPolicy : secondPolicy;
5732
- return { kind: "policy", policy: { kind: "any_of", policies: [owner, member] } };
5982
+ const owners = [firstPolicy, secondPolicy].filter((p) => p.kind === "owner");
5983
+ if (owners.length !== 1) throw new SchemaTsError(`line ${line}: ${ANY_OF_FORMS}`);
5984
+ const owner = owners[0];
5985
+ const other = firstPolicy.kind === "owner" ? secondPolicy : firstPolicy;
5986
+ return { kind: "policy", policy: { kind: "any_of", policies: [owner, other] } };
5733
5987
  }
5734
5988
  function readParentScope(r, tableName, line) {
5735
5989
  r.expectPunct("(", 'after "parent"');
@@ -6061,11 +6315,12 @@ function readTable(r, rawName, nameLine) {
6061
6315
  if (columns.filter((column) => column.helper === "id").length !== 1) {
6062
6316
  throw new SchemaTsError(`line ${nameLine}: table "${rawName}": client operations require exactly one id() column.`);
6063
6317
  }
6064
- const policyMember = scope.kind === "policy" && scope.policy.kind === "any_of" ? scope.policy.policies.find((node) => node.kind === "member") : void 0;
6318
+ const branch = scope.kind === "policy" && scope.policy.kind === "any_of" ? scope.policy.policies.find((node) => node.kind === "member" || node.kind === "group") : void 0;
6319
+ const branchGroup = branch?.kind === "member" ? branch.group : branch?.kind === "group" ? [branch.column] : void 0;
6065
6320
  const parsed = parseClientPermissions(clientInput, columns, {
6066
6321
  ...scope,
6067
6322
  ...managedOwner ? { column: managedOwner.column } : {},
6068
- ...policyMember ? { group: policyMember.group } : {}
6323
+ ...branchGroup ? { group: branchGroup } : {}
6069
6324
  });
6070
6325
  if (!parsed.ok) throw new SchemaTsError(`line ${nameLine}: table "${rawName}": ${parsed.message}`);
6071
6326
  const key = columns.find((column) => column.helper === "id").name;
@@ -6215,6 +6470,7 @@ function extractSchemaTs(source) {
6215
6470
  }
6216
6471
  }
6217
6472
  validateDeclaredRelations(tables, tableByName, knownColumns, (table) => removedTables.includes(table) ? "removedTable()" : exportedTables.includes(table) ? "exported()" : null, errors);
6473
+ validateRowLinks(tables, tableByName, errors);
6218
6474
  if (fileCollections) {
6219
6475
  errors.push(...fileErrors);
6220
6476
  validateFileCollections(fileCollections, (name) => {