@somewhere-tech/cli 0.37.10 → 0.37.11
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.
|
@@ -54,7 +54,7 @@ var require_declared_data_contract = __commonJS({
|
|
|
54
54
|
if (result.some((field) => typeof field !== "string" || !columns.has(field)) || new Set(result).size !== result.length) fail(name);
|
|
55
55
|
return result.sort();
|
|
56
56
|
}
|
|
57
|
-
function policy(value, label) {
|
|
57
|
+
function policy(value, label, edges = 0) {
|
|
58
58
|
record(value, label);
|
|
59
59
|
const keys = Object.keys(value).sort().join(",");
|
|
60
60
|
if (value.k === "o" && keys === "c,k" && identifier.test(value.c)) return { k: "o", c: value.c };
|
|
@@ -71,13 +71,31 @@ var require_declared_data_contract = __commonJS({
|
|
|
71
71
|
if (left.k !== "o" || right.k !== "m") fail(label);
|
|
72
72
|
return { k: "a", p: [left, right] };
|
|
73
73
|
}
|
|
74
|
+
if (value.k === "g" && ["c,k,roles", "c,generation,k,roles"].includes(keys) && identifier.test(value.c)) {
|
|
75
|
+
return { k: "g", c: value.c, roles: policyRoles(value.roles, label) };
|
|
76
|
+
}
|
|
77
|
+
if (value.k === "r" && ["k,roles", "generation,k,roles"].includes(keys)) {
|
|
78
|
+
return { k: "r", roles: policyRoles(value.roles, label) };
|
|
79
|
+
}
|
|
74
80
|
if (value.k === "p" && keys === "k,p,pk,t,v" && [value.v, value.t, value.pk].every((item) => typeof item === "string" && identifier.test(item))) {
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
if (edges + 1 > 4) fail(label);
|
|
82
|
+
const inherited = policy(value.p, label, edges + 1);
|
|
83
|
+
if (!["o", "m", "a", "p", "g", "r"].includes(inherited.k)) fail(label);
|
|
77
84
|
return { k: "p", v: value.v, t: value.t, pk: value.pk, p: inherited };
|
|
78
85
|
}
|
|
79
86
|
fail(label);
|
|
80
87
|
}
|
|
88
|
+
function policyRoles(value, label) {
|
|
89
|
+
record(value, label + ".roles");
|
|
90
|
+
if (Object.keys(value).sort().join(",") !== "create,delete,read,update") fail(label);
|
|
91
|
+
const roles = {};
|
|
92
|
+
for (const op of ["read", "create", "update", "delete"]) {
|
|
93
|
+
const list = value[op];
|
|
94
|
+
if (!Array.isArray(list) || list.some((role) => typeof role !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(role)) || new Set(list).size !== list.length) fail(label);
|
|
95
|
+
roles[op] = [...list].sort();
|
|
96
|
+
}
|
|
97
|
+
return roles;
|
|
98
|
+
}
|
|
81
99
|
function policyMembershipTables(value, out) {
|
|
82
100
|
if (!value || typeof value !== "object") return;
|
|
83
101
|
if (value.k === "m" && value.m) out.add(value.m.m);
|
|
@@ -164,7 +182,8 @@ var require_declared_data_contract = __commonJS({
|
|
|
164
182
|
member = { g: [...g], m, u, mg: [...mg], ...normalizedOperations !== void 0 ? { o: [...normalizedOperations] } : {} };
|
|
165
183
|
}
|
|
166
184
|
const normalizedPolicy = intent === "policy" ? policy(table.policy, `${name}.policy`) : null;
|
|
167
|
-
if (normalizedPolicy && !["a", "p"].includes(normalizedPolicy.k)) fail(`${name}.policy root`);
|
|
185
|
+
if (normalizedPolicy && !["a", "p", "g", "r"].includes(normalizedPolicy.k)) fail(`${name}.policy root`);
|
|
186
|
+
if (normalizedPolicy?.k === "g" && create !== null && !create.includes(normalizedPolicy.c)) fail(`${name}.policy group create`);
|
|
168
187
|
if (normalizedPolicy?.k === "a") {
|
|
169
188
|
owner = normalizedPolicy.p[0].c;
|
|
170
189
|
if ([...create || [], ...update || []].includes(owner) || (update || []).some((field) => normalizedPolicy.p[1].m.g.includes(field))) fail(`${name}.policy identity writes`);
|
|
@@ -337,11 +356,175 @@ var require_typed_data = __commonJS({
|
|
|
337
356
|
}
|
|
338
357
|
});
|
|
339
358
|
|
|
359
|
+
// worker/containers/compile/groups-types.cjs
|
|
360
|
+
var require_groups_types = __commonJS({
|
|
361
|
+
"worker/containers/compile/groups-types.cjs"(exports2, module2) {
|
|
362
|
+
"use strict";
|
|
363
|
+
var ROLE_NAME2 = /^[a-z][a-z0-9_]{0,63}$/;
|
|
364
|
+
var BUILT_IN_GROUP_ROLES3 = ["owner", "admin", "member"];
|
|
365
|
+
function fail(detail) {
|
|
366
|
+
throw new Error(`Invalid declared roles: ${detail}`);
|
|
367
|
+
}
|
|
368
|
+
function roleList(value, field) {
|
|
369
|
+
if (!Array.isArray(value)) fail(`${field} must be an array`);
|
|
370
|
+
const seen = /* @__PURE__ */ new Set();
|
|
371
|
+
for (const name of value) {
|
|
372
|
+
if (typeof name !== "string" || !ROLE_NAME2.test(name)) fail(`${field} has an invalid role name`);
|
|
373
|
+
if (seen.has(name)) fail(`${field} repeats ${name}`);
|
|
374
|
+
seen.add(name);
|
|
375
|
+
}
|
|
376
|
+
return [...value];
|
|
377
|
+
}
|
|
378
|
+
function declaredRoles2(text) {
|
|
379
|
+
if (text === void 0) return {};
|
|
380
|
+
if (typeof text !== "string") fail("expected canonical JSON text");
|
|
381
|
+
let value;
|
|
382
|
+
try {
|
|
383
|
+
value = JSON.parse(text);
|
|
384
|
+
} catch {
|
|
385
|
+
fail("not JSON");
|
|
386
|
+
}
|
|
387
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.v !== 1) fail("expected { v: 1 }");
|
|
388
|
+
const keys = Object.keys(value).sort().join(",");
|
|
389
|
+
if (keys !== "adminGrantableGroupRoles,appRoles,groupRoles,v") fail("unexpected fields");
|
|
390
|
+
const groupRoles = roleList(value.groupRoles, "groupRoles");
|
|
391
|
+
if (groupRoles.some((name) => BUILT_IN_GROUP_ROLES3.includes(name))) fail("groupRoles repeats a built-in role");
|
|
392
|
+
const grantable = roleList(value.adminGrantableGroupRoles, "adminGrantableGroupRoles");
|
|
393
|
+
if (grantable.some((name) => !groupRoles.includes(name))) fail("adminGrantableGroupRoles names an undeclared role");
|
|
394
|
+
if (!Array.isArray(value.appRoles)) fail("appRoles must be an array");
|
|
395
|
+
const appRoles = roleList(value.appRoles.map((role) => {
|
|
396
|
+
if (!role || typeof role !== "object" || Array.isArray(role) || Object.keys(role).sort().join(",") !== "grantableBy,name") fail("appRoles entry");
|
|
397
|
+
return role.name;
|
|
398
|
+
}), "appRoles");
|
|
399
|
+
for (const role of value.appRoles) {
|
|
400
|
+
if (roleList(role.grantableBy, "grantableBy").some((name) => !appRoles.includes(name))) fail("grantableBy names an undeclared app role");
|
|
401
|
+
}
|
|
402
|
+
return { groupRoles, appRoles };
|
|
403
|
+
}
|
|
404
|
+
function groupsTypeDeclarations(roles = {}) {
|
|
405
|
+
const literal = (names) => names.map((name) => JSON.stringify(name)).join(" | ");
|
|
406
|
+
const groupRoles = literal([...BUILT_IN_GROUP_ROLES3, ...roles.groupRoles || []]);
|
|
407
|
+
const appRoles = (roles.appRoles || []).length ? literal(roles.appRoles) : "never";
|
|
408
|
+
return `
|
|
409
|
+
type SomewhereGroupRole = ${groupRoles};
|
|
410
|
+
type SomewhereAppRole = ${appRoles};
|
|
411
|
+
|
|
412
|
+
interface SomewhereGroup {
|
|
413
|
+
id: string;
|
|
414
|
+
name: string;
|
|
415
|
+
created_by: string;
|
|
416
|
+
created_at: number;
|
|
417
|
+
updated_at: number;
|
|
418
|
+
/** The signed-in user's role in this group. */
|
|
419
|
+
role: SomewhereGroupRole;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
interface SomewhereGroupMember {
|
|
423
|
+
user_id: string;
|
|
424
|
+
role: SomewhereGroupRole;
|
|
425
|
+
created_at: number;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The group plus the FIRST roster page; continue with members(req, id, { cursor: members_next_cursor }). */
|
|
429
|
+
interface SomewhereGroupDetail extends SomewhereGroup {
|
|
430
|
+
members: SomewhereGroupMember[];
|
|
431
|
+
members_next_cursor: string | null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** One keyset page; next_cursor is null when nothing follows. No total count. */
|
|
435
|
+
interface SomewhereGroupsPage<T> {
|
|
436
|
+
items: T[];
|
|
437
|
+
next_cursor: string | null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** limit 1\u2013100 (default 50); cursor is a previous page's next_cursor. */
|
|
441
|
+
interface SomewhereGroupsPageOptions {
|
|
442
|
+
cursor?: string;
|
|
443
|
+
limit?: number;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
interface SomewhereAppRoleGrant {
|
|
447
|
+
user_id: string;
|
|
448
|
+
role: SomewhereAppRole;
|
|
449
|
+
granted_by: string;
|
|
450
|
+
grant_source: 'platform_admin' | 'delegation';
|
|
451
|
+
created_at: number;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A sent group invitation: one email; acceptance completes the membership. */
|
|
455
|
+
interface SomewhereGroupInvitation {
|
|
456
|
+
invite: { id: string; email: string; status: 'pending'; redirect_uri: string; expires_at: number; created_at: number };
|
|
457
|
+
group: { group_id: string; role: SomewhereGroupRole };
|
|
458
|
+
delivery: 'sent' | 'pending';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Where an accepted invitation's membership stands. pending: not confirmed
|
|
463
|
+
* yet (the platform retries on this user's next list); refused: it will not
|
|
464
|
+
* complete (code says why, e.g. INVITATION_REVOKED, INVITATION_AUTHORITY_LOST).
|
|
465
|
+
*/
|
|
466
|
+
interface SomewhereGroupInvitationOutcome {
|
|
467
|
+
invite_id: string;
|
|
468
|
+
group_id: string;
|
|
469
|
+
role: SomewhereGroupRole;
|
|
470
|
+
state: 'completed' | 'pending' | 'refused';
|
|
471
|
+
code?: string;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Expected refusals and unconfirmed outcomes resolve with retry:false; changes are never re-sent. */
|
|
475
|
+
interface SomewhereGroupsError {
|
|
476
|
+
code: string;
|
|
477
|
+
message: string;
|
|
478
|
+
status: number;
|
|
479
|
+
retry: false;
|
|
480
|
+
data?: Record<string, unknown>;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
type SomewhereGroupsResult<T> = { data: T; error: null } | { data: null; error: SomewhereGroupsError };
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Every method acts as the user signed in on the request this function was
|
|
487
|
+
* invoked with, read once from that original request. The \`req\` argument is
|
|
488
|
+
* accepted for compatibility and never read: passing another Request cannot
|
|
489
|
+
* change who acts, and no method accepts an acting user. Groups are available
|
|
490
|
+
* on the live app only; a preview, a dev run, a job delivery or a signed-out
|
|
491
|
+
* request resolves with data:null and a non-retryable error before any change.
|
|
492
|
+
*/
|
|
493
|
+
interface SomewhereGroups {
|
|
494
|
+
create(req: Request, input: { name: string }): Promise<SomewhereGroupsResult<SomewhereGroupDetail>>;
|
|
495
|
+
/** Also completes (and reports) up to 3 of the signed-in user's accepted invitations. */
|
|
496
|
+
list(req: Request, opts?: SomewhereGroupsPageOptions): Promise<SomewhereGroupsResult<SomewhereGroupsPage<SomewhereGroup> & { invitations: SomewhereGroupInvitationOutcome[] }>>;
|
|
497
|
+
get(req: Request, groupId: string): Promise<SomewhereGroupsResult<SomewhereGroupDetail>>;
|
|
498
|
+
members(req: Request, groupId: string, opts?: SomewhereGroupsPageOptions): Promise<SomewhereGroupsResult<SomewhereGroupsPage<SomewhereGroupMember>>>;
|
|
499
|
+
/** Invite by email with a role the signed-in user may grant. The platform
|
|
500
|
+
* sends one email and adds the member when they accept; no app call is needed. */
|
|
501
|
+
invite(req: Request, groupId: string, input: { email: string; role: SomewhereGroupRole; redirect_uri: string; expires_in?: number }): Promise<SomewhereGroupsResult<SomewhereGroupInvitation>>;
|
|
502
|
+
/** Revoke in the invitation's own group, as its sender or a member who may grant its role. */
|
|
503
|
+
revokeInvitation(req: Request, inviteId: string): Promise<SomewhereGroupsResult<{ revoked: true }>>;
|
|
504
|
+
leave(req: Request, groupId: string): Promise<SomewhereGroupsResult<{ left: true }>>;
|
|
505
|
+
remove(req: Request, groupId: string, userId: string): Promise<SomewhereGroupsResult<{ removed: true }>>;
|
|
506
|
+
setRole(req: Request, groupId: string, userId: string, role: SomewhereGroupRole): Promise<SomewhereGroupsResult<{ user_id: string; role: SomewhereGroupRole }>>;
|
|
507
|
+
appRoles: {
|
|
508
|
+
list(req: Request, userId?: string, opts?: SomewhereGroupsPageOptions): Promise<SomewhereGroupsResult<SomewhereGroupsPage<SomewhereAppRoleGrant>>>;
|
|
509
|
+
grant(req: Request, userId: string, role: SomewhereAppRole): Promise<SomewhereGroupsResult<SomewhereAppRoleGrant>>;
|
|
510
|
+
revoke(req: Request, userId: string, role: SomewhereAppRole): Promise<SomewhereGroupsResult<{ revoked: boolean }>>;
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
interface SomewhereRuntimeContext {
|
|
515
|
+
readonly groups: SomewhereGroups;
|
|
516
|
+
}
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
module2.exports = { groupsTypeDeclarations, declaredRoles: declaredRoles2 };
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
340
523
|
// worker/containers/compile/runtime-types.cjs
|
|
341
524
|
var require_runtime_types = __commonJS({
|
|
342
525
|
"worker/containers/compile/runtime-types.cjs"(exports2, module2) {
|
|
343
526
|
"use strict";
|
|
344
|
-
var
|
|
527
|
+
var BASE_RUNTIME_CONTEXT_DECLARATION = `
|
|
345
528
|
type __SomewhereJsonPrimitive = string | number | boolean | null;
|
|
346
529
|
type __SomewhereJson = __SomewhereJsonPrimitive | { [key: string]: __SomewhereJson } | __SomewhereJson[];
|
|
347
530
|
type SomewhereDbScalar = string | number | boolean | null;
|
|
@@ -1037,6 +1220,7 @@ type SomewherePaymentsStatus =
|
|
|
1037
1220
|
};
|
|
1038
1221
|
interface SomewherePaymentsLineItem { price?: string; amount?: number; currency?: string; name?: string; quantity?: number }
|
|
1039
1222
|
interface SomewherePaymentsCheckoutOptions {
|
|
1223
|
+
row?: never;
|
|
1040
1224
|
env?: SomewherePaymentsEnv;
|
|
1041
1225
|
mode?: 'payment' | 'subscription';
|
|
1042
1226
|
line_items?: readonly SomewherePaymentsLineItem[];
|
|
@@ -1050,6 +1234,44 @@ interface SomewherePaymentsCheckoutOptions {
|
|
|
1050
1234
|
customer_email?: string;
|
|
1051
1235
|
metadata?: Readonly<Record<string, string>>;
|
|
1052
1236
|
}
|
|
1237
|
+
/** Row checkout is a live, one-time payment; amounts are integer cents and items use one currency. */
|
|
1238
|
+
interface SomewherePaymentsRowCheckoutLineItem {
|
|
1239
|
+
name: string;
|
|
1240
|
+
amount: number;
|
|
1241
|
+
currency: string;
|
|
1242
|
+
quantity?: number;
|
|
1243
|
+
price?: never;
|
|
1244
|
+
}
|
|
1245
|
+
interface SomewherePaymentsRowCheckoutOptions {
|
|
1246
|
+
row: { binding: string; id: string | number };
|
|
1247
|
+
env?: 'prod';
|
|
1248
|
+
mode?: 'payment';
|
|
1249
|
+
line_items: readonly SomewherePaymentsRowCheckoutLineItem[];
|
|
1250
|
+
success_url: string;
|
|
1251
|
+
cancel_url: string;
|
|
1252
|
+
customer_email?: string;
|
|
1253
|
+
metadata?: never;
|
|
1254
|
+
quote_id?: never;
|
|
1255
|
+
booking_id?: never;
|
|
1256
|
+
calendar_hold_token?: never;
|
|
1257
|
+
plan?: never;
|
|
1258
|
+
}
|
|
1259
|
+
interface SomewherePaymentsRowCheckoutSessionResult {
|
|
1260
|
+
binding_id: string;
|
|
1261
|
+
session_id: string;
|
|
1262
|
+
url: string | null;
|
|
1263
|
+
reused: boolean;
|
|
1264
|
+
stripe_mode: 'live';
|
|
1265
|
+
amount_total_cents: number;
|
|
1266
|
+
currency: string;
|
|
1267
|
+
}
|
|
1268
|
+
/** A pending result has no checkout URL; reconciliation_required:true needs operator reconciliation. */
|
|
1269
|
+
interface SomewherePaymentsRowCheckoutPendingResult {
|
|
1270
|
+
binding_id: string;
|
|
1271
|
+
status: 'pending';
|
|
1272
|
+
reconciliation_required: boolean;
|
|
1273
|
+
}
|
|
1274
|
+
type SomewherePaymentsRowCheckoutResult = SomewherePaymentsRowCheckoutSessionResult | SomewherePaymentsRowCheckoutPendingResult;
|
|
1053
1275
|
interface SomewherePaymentsCheckoutForUserOptions extends SomewherePaymentsCheckoutOptions { plan: string }
|
|
1054
1276
|
interface SomewherePaymentsCheckoutResult {
|
|
1055
1277
|
session_id: string;
|
|
@@ -1138,6 +1360,7 @@ interface SomewhereRuntimePayments {
|
|
|
1138
1360
|
onboard(opts?: SomewherePaymentsOnboardOptions | null): Promise<SomewherePaymentsOnboardResult>;
|
|
1139
1361
|
quote: SomewhereQuoteFunction;
|
|
1140
1362
|
status(opts?: { refresh?: boolean } | null): Promise<SomewherePaymentsStatus>;
|
|
1363
|
+
checkout(opts: SomewherePaymentsRowCheckoutOptions): Promise<SomewherePaymentsRowCheckoutResult>;
|
|
1141
1364
|
checkout(opts: SomewherePaymentsCheckoutOptions): Promise<SomewherePaymentsCheckoutResult>;
|
|
1142
1365
|
// The app user is the request's verified principal; no user id argument.
|
|
1143
1366
|
checkoutForUser(opts: SomewherePaymentsCheckoutForUserOptions): Promise<SomewherePaymentsCheckoutResult>;
|
|
@@ -2700,6 +2923,10 @@ interface SomewhereJobsCreateOptions {
|
|
|
2700
2923
|
// Earliest start: an ISO-8601 time with an explicit offset, e.g. '2026-10-05T09:00:00Z'. Must be in the future.
|
|
2701
2924
|
run_at?: string;
|
|
2702
2925
|
agent?: { messages: readonly unknown[]; max_steps?: number; max_turns?: number; deployment_version?: string };
|
|
2926
|
+
// Row-bound job: acts for the signed-in user on ONE row of a job binding declared in db/schema.ts, with only that
|
|
2927
|
+
// binding's operations, until its deadline. id is the row's id(): a whole number, or a string for uuid ids.
|
|
2928
|
+
// Created only from that user's own request (not from another job); not combined with agent.
|
|
2929
|
+
row?: { binding: string; id: number | string };
|
|
2703
2930
|
}
|
|
2704
2931
|
interface SomewhereJobsRecovery {
|
|
2705
2932
|
dispatch_state: string;
|
|
@@ -2944,6 +3171,11 @@ type ServerFunction<Contract extends { input: unknown; output: unknown }> =
|
|
|
2944
3171
|
interface Response { readonly webSocket: WebSocket | null }
|
|
2945
3172
|
interface WebSocket { accept(): void }
|
|
2946
3173
|
`;
|
|
3174
|
+
var { groupsTypeDeclarations } = require_groups_types();
|
|
3175
|
+
function runtimeContextDeclaration2(roles) {
|
|
3176
|
+
return BASE_RUNTIME_CONTEXT_DECLARATION + groupsTypeDeclarations(roles);
|
|
3177
|
+
}
|
|
3178
|
+
var RUNTIME_CONTEXT_DECLARATION2 = runtimeContextDeclaration2({});
|
|
2947
3179
|
var ENDPOINT_DECLARATION2 = `
|
|
2948
3180
|
interface SomewhereEndpointUser {
|
|
2949
3181
|
id: string;
|
|
@@ -3017,7 +3249,7 @@ interface SomewhereEndpointConfig<Auth extends SomewhereEndpointAuth, Schema ext
|
|
|
3017
3249
|
});
|
|
3018
3250
|
return "interface SomewhereDeclaredTables {\n" + entries.join("") + "}\n";
|
|
3019
3251
|
}
|
|
3020
|
-
module2.exports = { RUNTIME_CONTEXT_DECLARATION: RUNTIME_CONTEXT_DECLARATION2, ENDPOINT_DECLARATION: ENDPOINT_DECLARATION2, declaredTablesDeclaration: declaredTablesDeclaration2 };
|
|
3252
|
+
module2.exports = { RUNTIME_CONTEXT_DECLARATION: RUNTIME_CONTEXT_DECLARATION2, ENDPOINT_DECLARATION: ENDPOINT_DECLARATION2, declaredTablesDeclaration: declaredTablesDeclaration2, runtimeContextDeclaration: runtimeContextDeclaration2 };
|
|
3021
3253
|
}
|
|
3022
3254
|
});
|
|
3023
3255
|
|
|
@@ -3038,20 +3270,32 @@ var require_typed_files = __commonJS({
|
|
|
3038
3270
|
const scope = collection.scope || {};
|
|
3039
3271
|
return scope.kind === "member" || scope.kind === "owner_or_member" ? scope.member : null;
|
|
3040
3272
|
}
|
|
3041
|
-
function
|
|
3273
|
+
function fieldOf(collection) {
|
|
3042
3274
|
const member = memberOf(collection);
|
|
3043
|
-
|
|
3044
|
-
const
|
|
3045
|
-
|
|
3275
|
+
if (member) return { name: member.group, input: "string | number", meta: "string", parent: false };
|
|
3276
|
+
const scope = collection.scope || {};
|
|
3277
|
+
if (scope.kind === "parent") {
|
|
3278
|
+
const type = scope.parent.keyType === "text" ? "string" : "number";
|
|
3279
|
+
return { name: scope.parent.via, input: type, meta: type, parent: true };
|
|
3280
|
+
}
|
|
3281
|
+
return null;
|
|
3282
|
+
}
|
|
3283
|
+
function describeCollection(collection) {
|
|
3284
|
+
const field = fieldOf(collection);
|
|
3285
|
+
const fieldKey = field ? JSON.stringify(field.name) : null;
|
|
3286
|
+
const meta = field ? `FileMeta & { ${fieldKey}: ${field.meta} }` : "FileMeta";
|
|
3287
|
+
const fieldOption = field ? `${fieldKey}: ${field.input}; ` : "";
|
|
3288
|
+
const listOptions = field && field.parent ? `options: { ${fieldOption}limit?: number; cursor?: string | null }` : `options?: { ${field ? `${fieldKey}?: ${field.input}; ` : ""}limit?: number; cursor?: string | null }`;
|
|
3046
3289
|
const ops = [];
|
|
3047
3290
|
if (collection.client.read) {
|
|
3048
|
-
ops.push(`list(
|
|
3291
|
+
ops.push(`list(${listOptions}): Promise<FileResult<{ items: Array<${meta}>; next: string | null; has_more: boolean }>>`);
|
|
3049
3292
|
ops.push(`get(id: string): Promise<FileResult<${meta}>>`);
|
|
3050
3293
|
ops.push("url(id: string): string");
|
|
3051
3294
|
ops.push("shareLink(id: string, options?: { expiresIn?: number }): Promise<FileResult<{ url: string; expires_at: string; expires_in: number }>>");
|
|
3052
3295
|
}
|
|
3053
|
-
if (collection.client.upload) ops.push(`upload(file: Blob, options${
|
|
3296
|
+
if (collection.client.upload) ops.push(`upload(file: Blob, options${field ? "" : "?"}: { ${fieldOption}name?: string; contentType?: string; onProgress?: (progress: { loaded: number; total: number }) => void }): Promise<FileResult<UploadedFile>>`);
|
|
3054
3297
|
if (collection.client.replace) ops.push("replace(id: string, file: Blob, options?: { contentType?: string; onProgress?: (progress: { loaded: number; total: number }) => void }): Promise<FileResult<UploadedFile>>");
|
|
3298
|
+
if (collection.client.replace && field && field.parent) ops.push(`move(id: string, to: { ${fieldOption}}): Promise<FileResult<${meta}>>`);
|
|
3055
3299
|
if (collection.client.delete) ops.push("delete(id: string): Promise<FileResult<{ id: string; deleted: true }>>");
|
|
3056
3300
|
if (collection.public) {
|
|
3057
3301
|
ops.push(collection.scope.kind === "server_only" ? "publicUrl(path: string): string" : "publicUrl(id: string, name: string): string");
|
|
@@ -3071,13 +3315,14 @@ var require_typed_files = __commonJS({
|
|
|
3071
3315
|
}
|
|
3072
3316
|
`;
|
|
3073
3317
|
const shapes = collections.map((c) => {
|
|
3074
|
-
const
|
|
3318
|
+
const field = fieldOf(c);
|
|
3075
3319
|
return {
|
|
3076
3320
|
name: c.name,
|
|
3077
3321
|
root: c.path,
|
|
3078
3322
|
ops: Object.keys(c.client).filter((op) => c.client[op]),
|
|
3079
3323
|
public: c.public ? c.scope.kind === "server_only" ? "path" : "id" : null,
|
|
3080
|
-
group:
|
|
3324
|
+
group: field ? field.name : null,
|
|
3325
|
+
move: Boolean(field && field.parent && c.client.replace),
|
|
3081
3326
|
maxSize: c.limits.maxSize,
|
|
3082
3327
|
types: c.limits.types
|
|
3083
3328
|
};
|
|
@@ -3150,6 +3395,7 @@ function makeCollection(shape){
|
|
|
3150
3395
|
if(has("upload"))c.upload=(file,options)=>put("upload",null,file,options);
|
|
3151
3396
|
if(has("replace"))c.replace=(id,file,options)=>put("replace",id,file,options);
|
|
3152
3397
|
if(has("delete"))c.delete=id=>call(shape.name,"delete",{id});
|
|
3398
|
+
if(shape.move)c.move=(id,to)=>{const input={id};if(to&&to[shape.group]!==undefined)input[shape.group]=to[shape.group];return call(shape.name,"move",input)};
|
|
3153
3399
|
if(shape.public)c.publicUrl=publicUrl;
|
|
3154
3400
|
return Object.freeze(c);
|
|
3155
3401
|
}
|
|
@@ -3205,13 +3451,20 @@ var require_schema_types = __commonJS({
|
|
|
3205
3451
|
client?: Partial<Record<FileOperation, boolean>>;
|
|
3206
3452
|
limits?: { maxSize?: string | number; types?: string[] };
|
|
3207
3453
|
}
|
|
3454
|
+
type RowOperation = 'read' | 'update' | 'delete';
|
|
3455
|
+
interface PaymentBinding {
|
|
3456
|
+
table: string; amount: string; currency: string;
|
|
3457
|
+
onPaid: Record<string, { from: string[]; to: string }>;
|
|
3458
|
+
}
|
|
3459
|
+
interface JobBinding { table: string; operations: RowOperation[]; maxDurationSeconds: number }
|
|
3460
|
+
interface ServiceRole { tables: Record<string, Array<'read' | 'create' | 'update' | 'delete'>> }
|
|
3208
3461
|
interface TableOptions<Field extends string> {
|
|
3209
3462
|
scope?: Scope; client?: ClientPermissions<Field>; indexes?: string[][];
|
|
3210
3463
|
unique?: string[][]; relations?: Record<string, Relation>;
|
|
3211
3464
|
}
|
|
3212
3465
|
}
|
|
3213
3466
|
`;
|
|
3214
|
-
var signatures = `function schema(tables: Record<string, SomewhereSchemaDeclaration.Table | SomewhereSchemaDeclaration.TableMarker>, options?: { search?: SomewhereSchemaDeclaration.OwnerScope; files?: Record<string, SomewhereSchemaDeclaration.FileCollection> }): unknown;
|
|
3467
|
+
var signatures = `function schema(tables: Record<string, SomewhereSchemaDeclaration.Table | SomewhereSchemaDeclaration.TableMarker>, options?: { search?: SomewhereSchemaDeclaration.OwnerScope; files?: Record<string, SomewhereSchemaDeclaration.FileCollection>; groups?: { roles?: string[]; adminGrantableRoles?: string[] }; appRoles?: Record<string, { grantableBy?: string[] }>; payments?: Record<string, SomewhereSchemaDeclaration.PaymentBinding>; jobs?: Record<string, SomewhereSchemaDeclaration.JobBinding>; serviceRoles?: Record<string, SomewhereSchemaDeclaration.ServiceRole> }): unknown;
|
|
3215
3468
|
function table<Columns extends Record<string, SomewhereSchemaDeclaration.Column | SomewhereSchemaDeclaration.ColumnMarker>>(columns: Columns, options?: SomewhereSchemaDeclaration.TableOptions<Extract<keyof Columns, string>>): SomewhereSchemaDeclaration.Table;
|
|
3216
3469
|
function id(options?: { uuid?: boolean }): SomewhereSchemaDeclaration.Column;
|
|
3217
3470
|
function text(options?: SomewhereSchemaDeclaration.ColumnOptions<string>): SomewhereSchemaDeclaration.Column;
|
|
@@ -3235,7 +3488,9 @@ function exported(): SomewhereSchemaDeclaration.TableMarker;
|
|
|
3235
3488
|
`;
|
|
3236
3489
|
var policySignatures = `function anyOf(owner: SomewhereSchemaDeclaration.OwnerScope, member: SomewhereSchemaDeclaration.MemberScope): SomewhereSchemaDeclaration.Scope;
|
|
3237
3490
|
function anyOf(member: SomewhereSchemaDeclaration.MemberScope, owner: SomewhereSchemaDeclaration.OwnerScope): SomewhereSchemaDeclaration.Scope;
|
|
3238
|
-
function parent(options: { via: string }): SomewhereSchemaDeclaration.Scope;
|
|
3491
|
+
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;
|
|
3239
3494
|
`;
|
|
3240
3495
|
var SCHEMA_DECLARATION2 = types + signatures.replace(/^function /gm, "declare function ") + `declare module 'somewhere/db' {
|
|
3241
3496
|
${(signatures + policySignatures).replace(/^function /gm, " export function ")}}
|
|
@@ -3253,7 +3508,8 @@ __export(declared_data_vendor_entry_exports, {
|
|
|
3253
3508
|
SCHEMA_DECLARATION: () => import_schema_types.SCHEMA_DECLARATION,
|
|
3254
3509
|
declaredTablesFromFiles: () => declaredTablesFromFiles,
|
|
3255
3510
|
filesDeclarationFromFiles: () => filesDeclarationFromFiles,
|
|
3256
|
-
generateFromFiles: () => generateFromFiles
|
|
3511
|
+
generateFromFiles: () => generateFromFiles,
|
|
3512
|
+
runtimeDeclarationFromFiles: () => runtimeDeclarationFromFiles
|
|
3257
3513
|
});
|
|
3258
3514
|
module.exports = __toCommonJS(declared_data_vendor_entry_exports);
|
|
3259
3515
|
|
|
@@ -3386,8 +3642,236 @@ function parseClientPermissions(value, columns, scope) {
|
|
|
3386
3642
|
return { ok: true, permissions: { identity, read, publicRead, ...writes, delete: remove } };
|
|
3387
3643
|
}
|
|
3388
3644
|
|
|
3645
|
+
// worker/src/services/jobs/limits.ts
|
|
3646
|
+
var STALE_JOB_WINDOW_MS = 45 * 60 * 1e3;
|
|
3647
|
+
|
|
3648
|
+
// worker/src/utils/db-schema-deploy/extract-schema-bindings.ts
|
|
3649
|
+
var BINDING_OPERATIONS = ["read", "create", "update", "delete"];
|
|
3650
|
+
var ROW_OPERATIONS = ["read", "update", "delete"];
|
|
3651
|
+
var MAX_JOB_BINDING_SECONDS = STALE_JOB_WINDOW_MS / 1e3;
|
|
3652
|
+
var NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
3653
|
+
var BUILT_IN_GROUP_ROLES = ["admin", "member", "owner"];
|
|
3654
|
+
var lines = /* @__PURE__ */ new WeakMap();
|
|
3655
|
+
var byName = (list) => list.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
3656
|
+
function readString(r, fail, ctx) {
|
|
3657
|
+
const t = r.next();
|
|
3658
|
+
if (t.kind !== "string") fail(t.line, `${ctx} must be a quoted literal string.`);
|
|
3659
|
+
return t;
|
|
3660
|
+
}
|
|
3661
|
+
function readStrings(r, fail, ctx) {
|
|
3662
|
+
const open = r.next();
|
|
3663
|
+
if (open.kind !== "punct" || open.value !== "[") fail(open.line, `${ctx} must be a list of quoted literal strings, such as ['read'].`);
|
|
3664
|
+
const out = [];
|
|
3665
|
+
while (!r.tryPunct("]")) {
|
|
3666
|
+
const t = r.next();
|
|
3667
|
+
if (t.kind !== "string") fail(t.line, `${ctx} must be a list of quoted literal strings.`);
|
|
3668
|
+
if (out.some((seen) => seen.value === t.value)) fail(t.line, `${ctx} lists "${t.value}" twice.`);
|
|
3669
|
+
out.push(t);
|
|
3670
|
+
if (!r.tryPunct(",")) {
|
|
3671
|
+
r.expectPunct("]", `closing ${ctx}`);
|
|
3672
|
+
break;
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
return out;
|
|
3676
|
+
}
|
|
3677
|
+
function readOperations(r, fail, ctx, allowed) {
|
|
3678
|
+
const list = readStrings(r, fail, ctx);
|
|
3679
|
+
if (list.length === 0) fail(r.peek().line, `${ctx} must name at least one operation.`);
|
|
3680
|
+
for (const t of list) {
|
|
3681
|
+
if (!allowed.includes(t.value)) fail(t.line, `${ctx} has an unknown operation "${t.value}". Allowed: ${allowed.join(", ")}.`);
|
|
3682
|
+
}
|
|
3683
|
+
const ops = BINDING_OPERATIONS.filter((op) => list.some((t) => t.value === op));
|
|
3684
|
+
if (!ops.includes("read") && (ops.includes("update") || ops.includes("delete"))) {
|
|
3685
|
+
fail(list[0].line, `${ctx} allows ${ops.filter((op) => op !== "create").join(" and ")} without read. Update and delete return the existing row, so they also need read; create alone is allowed because it returns only the row it created.`);
|
|
3686
|
+
}
|
|
3687
|
+
return ops;
|
|
3688
|
+
}
|
|
3689
|
+
function readNamed(r, fail, option, kind, keys, readField) {
|
|
3690
|
+
r.expectPunct("{", `opening schema option ${option}`);
|
|
3691
|
+
const out = [];
|
|
3692
|
+
while (!r.tryPunct("}")) {
|
|
3693
|
+
const key = r.readKey(`for a ${kind} name`);
|
|
3694
|
+
if (!NAME.test(key.name)) fail(key.line, `${kind} name "${key.name}" must be lowercase letters, digits and underscores, starting with a letter (at most 64).`);
|
|
3695
|
+
if (out.some((entry) => entry.name === key.name)) fail(key.line, `${kind} "${key.name}" is declared twice.`);
|
|
3696
|
+
r.expectPunct(":", `after ${kind} "${key.name}"`);
|
|
3697
|
+
r.expectPunct("{", `opening ${kind} "${key.name}"`);
|
|
3698
|
+
const fields = {};
|
|
3699
|
+
const fieldLines = { "": key.line };
|
|
3700
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3701
|
+
while (!r.tryPunct("}")) {
|
|
3702
|
+
const opt = r.readKey(`for an option of ${kind} "${key.name}"`);
|
|
3703
|
+
if (!keys.includes(opt.name)) fail(opt.line, `${kind} "${key.name}" has an unknown option "${opt.name}". Allowed: ${keys.join(", ")}.`);
|
|
3704
|
+
if (seen.has(opt.name)) fail(opt.line, `${kind} "${key.name}" declares ${opt.name} twice.`);
|
|
3705
|
+
seen.add(opt.name);
|
|
3706
|
+
r.expectPunct(":", `after ${option}.${key.name}.${opt.name}`);
|
|
3707
|
+
fieldLines[opt.name] = r.peek().line;
|
|
3708
|
+
readField(opt.name, fields, key.name);
|
|
3709
|
+
if (!r.tryPunct(",")) {
|
|
3710
|
+
r.expectPunct("}", `closing ${kind} "${key.name}"`);
|
|
3711
|
+
break;
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
const missing = keys.filter((k) => !seen.has(k));
|
|
3715
|
+
if (missing.length) fail(key.line, `${kind} "${key.name}" is missing ${missing.join(", ")}.`);
|
|
3716
|
+
lines.set(fields, fieldLines);
|
|
3717
|
+
out.push({ name: key.name, line: key.line, fields });
|
|
3718
|
+
if (!r.tryPunct(",")) {
|
|
3719
|
+
r.expectPunct("}", `closing schema option ${option}`);
|
|
3720
|
+
break;
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
return out;
|
|
3724
|
+
}
|
|
3725
|
+
function readPaymentBindings(r, fail) {
|
|
3726
|
+
const entries = readNamed(r, fail, "payments", "payment binding", ["table", "amount", "currency", "onPaid"], (key, fields, name) => {
|
|
3727
|
+
if (key !== "onPaid") {
|
|
3728
|
+
fields[key] = readString(r, fail, `payments.${name}.${key}`).value.toLowerCase();
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
r.expectPunct("{", `opening payments.${name}.onPaid \u2014 declare one transition, { status: { from: [...], to: '...' } }`);
|
|
3732
|
+
const column = r.readKey(`for the status column in payments.${name}.onPaid`);
|
|
3733
|
+
r.expectPunct(":", `after payments.${name}.onPaid.${column.name}`);
|
|
3734
|
+
r.expectPunct("{", `opening payments.${name}.onPaid.${column.name} \u2014 declare { from: [...], to: '...' }`);
|
|
3735
|
+
const transition = {};
|
|
3736
|
+
while (!r.tryPunct("}")) {
|
|
3737
|
+
const opt = r.readKey(`for an option of payments.${name}.onPaid.${column.name}`);
|
|
3738
|
+
if (opt.name !== "from" && opt.name !== "to") fail(opt.line, `payments.${name}.onPaid.${column.name} has an unknown option "${opt.name}". Allowed: from, to.`);
|
|
3739
|
+
if (transition[opt.name]) fail(opt.line, `payments.${name}.onPaid.${column.name} declares ${opt.name} twice.`);
|
|
3740
|
+
r.expectPunct(":", `after ${opt.name}`);
|
|
3741
|
+
if (opt.name === "from") transition.from = readStrings(r, fail, `payments.${name}.onPaid.${column.name}.from`);
|
|
3742
|
+
else transition.to = readString(r, fail, `payments.${name}.onPaid.${column.name}.to`);
|
|
3743
|
+
if (!r.tryPunct(",")) {
|
|
3744
|
+
r.expectPunct("}", `closing payments.${name}.onPaid.${column.name}`);
|
|
3745
|
+
break;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
if (!transition.from || !transition.to) fail(column.line, `payments.${name}.onPaid.${column.name} needs both from and to.`);
|
|
3749
|
+
r.tryPunct(",");
|
|
3750
|
+
if (!r.tryPunct("}")) {
|
|
3751
|
+
const extra = r.peek();
|
|
3752
|
+
fail(extra.line, `payments.${name}.onPaid declares one status transition only; remove the second entry. Other paid effects run from the signed payment event.`);
|
|
3753
|
+
}
|
|
3754
|
+
const values = [...transition.from, transition.to];
|
|
3755
|
+
for (const t of values) {
|
|
3756
|
+
if (t.value.length < 1 || t.value.length > 64 || /[\u0000-\u001f]/.test(t.value)) fail(t.line, `payments.${name}.onPaid.${column.name} status values must be 1 to 64 characters, without control characters.`);
|
|
3757
|
+
}
|
|
3758
|
+
if (transition.from.length === 0) fail(column.line, `payments.${name}.onPaid.${column.name}.from must list at least one unpaid state.`);
|
|
3759
|
+
if (transition.from.some((t) => t.value === transition.to.value)) fail(transition.to.line, `payments.${name}.onPaid.${column.name}.to must differ from every from state.`);
|
|
3760
|
+
fields.status = { column: column.name.toLowerCase(), from: transition.from.map((t) => t.value).sort(), to: transition.to.value };
|
|
3761
|
+
});
|
|
3762
|
+
return byName(entries.map(({ name, fields }) => {
|
|
3763
|
+
const binding = { name, ...fields };
|
|
3764
|
+
lines.set(binding, lines.get(fields));
|
|
3765
|
+
return binding;
|
|
3766
|
+
}));
|
|
3767
|
+
}
|
|
3768
|
+
function readJobBindings(r, fail) {
|
|
3769
|
+
const entries = readNamed(r, fail, "jobs", "job binding", ["table", "operations", "maxDurationSeconds"], (key, fields, name) => {
|
|
3770
|
+
if (key === "table") fields.table = readString(r, fail, `jobs.${name}.table`).value.toLowerCase();
|
|
3771
|
+
else if (key === "operations") fields.operations = readOperations(r, fail, `jobs.${name}.operations`, ROW_OPERATIONS);
|
|
3772
|
+
else {
|
|
3773
|
+
const t = r.next();
|
|
3774
|
+
if (t.kind !== "number" || !/^[1-9][0-9]{0,9}$/.test(t.value) || Number(t.value) > MAX_JOB_BINDING_SECONDS) {
|
|
3775
|
+
fail(t.line, `jobs.${name}.maxDurationSeconds must be a whole number of seconds from 1 to ${MAX_JOB_BINDING_SECONDS}: how long the row grant lasts from the job's scheduled start, retries included.`);
|
|
3776
|
+
}
|
|
3777
|
+
fields.maxDurationSeconds = Number(t.value);
|
|
3778
|
+
}
|
|
3779
|
+
});
|
|
3780
|
+
return byName(entries.map(({ name, fields }) => {
|
|
3781
|
+
const binding = { name, ...fields };
|
|
3782
|
+
lines.set(binding, lines.get(fields));
|
|
3783
|
+
return binding;
|
|
3784
|
+
}));
|
|
3785
|
+
}
|
|
3786
|
+
function readServiceRoles(r, fail) {
|
|
3787
|
+
const entries = readNamed(r, fail, "serviceRoles", "service role", ["tables"], (_key, fields, name) => {
|
|
3788
|
+
r.expectPunct("{", `opening serviceRoles.${name}.tables \u2014 declare { table: ['read', ...] }`);
|
|
3789
|
+
const tables = [];
|
|
3790
|
+
const tableLines = {};
|
|
3791
|
+
while (!r.tryPunct("}")) {
|
|
3792
|
+
const t = r.readKey(`for a table in serviceRoles.${name}.tables`);
|
|
3793
|
+
const table = t.name.toLowerCase();
|
|
3794
|
+
if (tables.some((entry) => entry.table === table)) fail(t.line, `serviceRoles.${name}.tables names "${t.name}" twice.`);
|
|
3795
|
+
r.expectPunct(":", `after serviceRoles.${name}.tables.${t.name}`);
|
|
3796
|
+
tables.push({ table, operations: readOperations(r, fail, `serviceRoles.${name}.tables.${t.name}`, BINDING_OPERATIONS) });
|
|
3797
|
+
tableLines[table] = t.line;
|
|
3798
|
+
if (!r.tryPunct(",")) {
|
|
3799
|
+
r.expectPunct("}", `closing serviceRoles.${name}.tables`);
|
|
3800
|
+
break;
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
if (tables.length === 0) fail(r.peek().line, `serviceRoles.${name}.tables must grant at least one table.`);
|
|
3804
|
+
fields.tables = tables.sort((a, b) => a.table < b.table ? -1 : a.table > b.table ? 1 : 0);
|
|
3805
|
+
lines.set(fields.tables, tableLines);
|
|
3806
|
+
});
|
|
3807
|
+
return byName(entries.map(({ name, fields }) => {
|
|
3808
|
+
const role = { name, tables: fields.tables };
|
|
3809
|
+
lines.set(role, lines.get(fields));
|
|
3810
|
+
return role;
|
|
3811
|
+
}));
|
|
3812
|
+
}
|
|
3813
|
+
function validateBindings(input, tableInfo, errors) {
|
|
3814
|
+
const at = (entry, field) => `line ${lines.get(entry)?.[field] ?? lines.get(entry)?.[""] ?? 0}: `;
|
|
3815
|
+
const rowTable = (entry, kind) => {
|
|
3816
|
+
const info = tableInfo(entry.table);
|
|
3817
|
+
if (!info) {
|
|
3818
|
+
errors.push(`${at(entry, "table")}${kind} "${entry.name}" names table "${entry.table}", which is not declared in db/schema.ts.`);
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
if (!info.privateUser) errors.push(`${at(entry, "table")}${kind} "${entry.name}" names table "${entry.table}", whose scope is not owner(), member(), anyOf(), group(), appRole() or parent(). It binds rows a signed-in user holds; use a server function for other tables.`);
|
|
3822
|
+
if (!info.key) errors.push(`${at(entry, "table")}${kind} "${entry.name}" names table "${entry.table}", which needs exactly one id() key.`);
|
|
3823
|
+
return info;
|
|
3824
|
+
};
|
|
3825
|
+
for (const p of input.payments ?? []) {
|
|
3826
|
+
const info = rowTable(p, "payment binding");
|
|
3827
|
+
if (!info) continue;
|
|
3828
|
+
const column = (name) => info.table.columns.find((c) => c.name === name);
|
|
3829
|
+
const checks = [
|
|
3830
|
+
["amount", p.amount, "integer", "a required integer() column holding the amount in minor units (cents)"],
|
|
3831
|
+
["currency", p.currency, "text", "a required text() column holding the currency code"],
|
|
3832
|
+
["onPaid", p.status.column, "text", "a required text() status column"]
|
|
3833
|
+
];
|
|
3834
|
+
for (const [field, name, helper, what] of checks) {
|
|
3835
|
+
const c = column(name);
|
|
3836
|
+
if (!c) {
|
|
3837
|
+
errors.push(`${at(p, field)}payment binding "${p.name}" names "${name}", which is not a declared column of "${p.table}".`);
|
|
3838
|
+
continue;
|
|
3839
|
+
}
|
|
3840
|
+
if (c.helper !== helper || c.nullable) errors.push(`${at(p, field)}payment binding "${p.name}": "${p.table}"."${name}" must be ${what}.`);
|
|
3841
|
+
if (info.identity.includes(name)) errors.push(`${at(p, field)}payment binding "${p.name}": "${p.table}"."${name}" is the table's access column; a payment never moves a row.`);
|
|
3842
|
+
const writable = [...info.table.client?.create ?? [], ...info.table.client?.update ?? []];
|
|
3843
|
+
if (writable.includes(name)) errors.push(`${at(p, field)}payment binding "${p.name}": "${p.table}"."${name}" is browser-writable through client.create or client.update. Remove it there; the server sets prices and the payment sets the status.`);
|
|
3844
|
+
}
|
|
3845
|
+
if ((/* @__PURE__ */ new Set([p.amount, p.currency, p.status.column])).size !== 3) errors.push(`${at(p, "")}payment binding "${p.name}" must name three different columns for amount, currency and status.`);
|
|
3846
|
+
const status = column(p.status.column);
|
|
3847
|
+
if (status && !(status.hasDefault && typeof status.default === "string" && p.status.from.includes(status.default))) {
|
|
3848
|
+
errors.push(`${at(p, "onPaid")}payment binding "${p.name}": "${p.table}"."${p.status.column}" must declare a default that is one of its unpaid from states (${p.status.from.join(", ")}), so a new row starts unpaid.`);
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
for (const j of input.jobs ?? []) rowTable(j, "job binding");
|
|
3852
|
+
const taken = /* @__PURE__ */ new Set([...BUILT_IN_GROUP_ROLES, ...input.groups?.roles ?? [], ...(input.appRoles ?? []).map((role) => role.name)]);
|
|
3853
|
+
for (const s of input.serviceRoles ?? []) {
|
|
3854
|
+
if (taken.has(s.name)) errors.push(`${at(s, "")}service role "${s.name}" has the name of a group or app role. Service roles are a separate namespace for unattended work; pick another name.`);
|
|
3855
|
+
const tableLines = lines.get(s.tables) ?? {};
|
|
3856
|
+
for (const grant of s.tables) {
|
|
3857
|
+
if (!tableInfo(grant.table)) errors.push(`line ${tableLines[grant.table] ?? 0}: service role "${s.name}" grants table "${grant.table}", which is not declared in db/schema.ts.`);
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3389
3862
|
// worker/src/utils/authority/scope-enforcement.ts
|
|
3390
3863
|
var SAFE_SCOPE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3864
|
+
var GROUP_ROLE_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
3865
|
+
function validGroupRoles(value) {
|
|
3866
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
3867
|
+
const roles = value;
|
|
3868
|
+
return exactKeys(roles, ["read", "create", "update", "delete"]) && ["read", "create", "update", "delete"].every((op) => Array.isArray(roles[op]) && roles[op].every((role) => typeof role === "string" && GROUP_ROLE_NAME.test(role)) && new Set(roles[op]).size === roles[op].length);
|
|
3869
|
+
}
|
|
3870
|
+
function exactKeys(value, keys) {
|
|
3871
|
+
const actual = Object.keys(value).sort();
|
|
3872
|
+
return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index]);
|
|
3873
|
+
}
|
|
3874
|
+
var MAX_PARENT_EDGES = 4;
|
|
3391
3875
|
function bakedTableSchemaFromDeclared(declaredJson) {
|
|
3392
3876
|
let shape;
|
|
3393
3877
|
try {
|
|
@@ -3462,7 +3946,7 @@ function bakedTableSchemaFromDeclared(declaredJson) {
|
|
|
3462
3946
|
}
|
|
3463
3947
|
if (scope !== null && typeof scope === "object" && scope.kind === "policy") {
|
|
3464
3948
|
const policy = bakedPolicyFromDeclared(scope.policy);
|
|
3465
|
-
if (!policy || policy.k !== "a" && policy.k !== "p") return null;
|
|
3949
|
+
if (!policy || policy.k !== "a" && policy.k !== "p" && policy.k !== "g" && policy.k !== "r") return null;
|
|
3466
3950
|
return relations ? { columns: out, policy, relations, ...browser } : { columns: out, policy, ...browser };
|
|
3467
3951
|
}
|
|
3468
3952
|
const author = scope !== null && typeof scope === "object" && scope.kind === "shared" ? SHARED_AUTHOR_COLUMN : void 0;
|
|
@@ -3479,8 +3963,8 @@ function declaredPolicyIdentity(value) {
|
|
|
3479
3963
|
}
|
|
3480
3964
|
return {};
|
|
3481
3965
|
}
|
|
3482
|
-
function bakedPolicyFromDeclared(value, depth = 0) {
|
|
3483
|
-
if (
|
|
3966
|
+
function bakedPolicyFromDeclared(value, depth = 0, edges = 0, underParent = false) {
|
|
3967
|
+
if (edges > MAX_PARENT_EDGES || value === null || typeof value !== "object") return null;
|
|
3484
3968
|
const node = value;
|
|
3485
3969
|
if (node.kind === "owner") {
|
|
3486
3970
|
return typeof node.column === "string" && SAFE_SCOPE_IDENTIFIER.test(node.column) ? { k: "o", c: node.column.toLowerCase() } : null;
|
|
@@ -3490,14 +3974,26 @@ function bakedPolicyFromDeclared(value, depth = 0) {
|
|
|
3490
3974
|
return member ? { k: "m", m: member } : null;
|
|
3491
3975
|
}
|
|
3492
3976
|
if (node.kind === "any_of" && Array.isArray(node.policies) && node.policies.length === 2) {
|
|
3493
|
-
if (depth
|
|
3977
|
+
if (depth !== 0 && !underParent || node.policies[0]?.kind !== "owner" || node.policies[1]?.kind !== "member") return null;
|
|
3494
3978
|
const left = bakedPolicyFromDeclared(node.policies[0], depth + 1);
|
|
3495
3979
|
const right = bakedPolicyFromDeclared(node.policies[1], depth + 1);
|
|
3496
3980
|
return left && right ? { k: "a", p: [left, right] } : null;
|
|
3497
3981
|
}
|
|
3982
|
+
if (node.kind === "group") {
|
|
3983
|
+
if (depth !== 0 && !underParent) return null;
|
|
3984
|
+
const roles = node.roles;
|
|
3985
|
+
const baked = { read: roles?.read, create: roles?.create, update: roles?.update, delete: roles?.delete };
|
|
3986
|
+
return typeof node.column === "string" && SAFE_SCOPE_IDENTIFIER.test(node.column) && validGroupRoles(baked) ? { k: "g", c: node.column.toLowerCase(), roles: { read: [...baked.read], create: [...baked.create], update: [...baked.update], delete: [...baked.delete] }, generation: 0 } : null;
|
|
3987
|
+
}
|
|
3988
|
+
if (node.kind === "app_role") {
|
|
3989
|
+
if (depth !== 0 && !underParent || !exactKeys(node, ["kind", "roles"]) || !validGroupRoles(node.roles)) return null;
|
|
3990
|
+
const roles = node.roles;
|
|
3991
|
+
const sorted3 = (list) => list.every((role, i) => i === 0 || list[i - 1] < role);
|
|
3992
|
+
return ["read", "create", "update", "delete"].every((op) => sorted3(roles[op])) ? { k: "r", roles: { read: [...roles.read], create: [...roles.create], update: [...roles.update], delete: [...roles.delete] }, generation: 0 } : null;
|
|
3993
|
+
}
|
|
3498
3994
|
if (node.kind === "parent" && typeof node.via === "string" && SAFE_SCOPE_IDENTIFIER.test(node.via) && typeof node.table === "string" && SAFE_SCOPE_IDENTIFIER.test(node.table) && typeof node.parentKey === "string" && SAFE_SCOPE_IDENTIFIER.test(node.parentKey)) {
|
|
3499
|
-
if (depth !== 0 ||
|
|
3500
|
-
const inherited = bakedPolicyFromDeclared(node.policy, depth + 1);
|
|
3995
|
+
if (depth !== 0 && !underParent || edges + 1 > MAX_PARENT_EDGES) return null;
|
|
3996
|
+
const inherited = bakedPolicyFromDeclared(node.policy, depth + 1, edges + 1, true);
|
|
3501
3997
|
return inherited ? { k: "p", v: node.via.toLowerCase(), t: node.table.toLowerCase(), pk: node.parentKey.toLowerCase(), p: inherited } : null;
|
|
3502
3998
|
}
|
|
3503
3999
|
return null;
|
|
@@ -3777,6 +4273,90 @@ function matchingClose(tokens, open) {
|
|
|
3777
4273
|
return -1;
|
|
3778
4274
|
}
|
|
3779
4275
|
|
|
4276
|
+
// worker/src/utils/db-schema-deploy/extract-schema-parents.ts
|
|
4277
|
+
var MAX_PARENT_EDGES2 = 4;
|
|
4278
|
+
var parentScopeLines = /* @__PURE__ */ new WeakMap();
|
|
4279
|
+
function rememberParentLine(sentinel, line) {
|
|
4280
|
+
parentScopeLines.set(sentinel, line);
|
|
4281
|
+
}
|
|
4282
|
+
function resolveParentChains(tables, tableByName, privatePolicy, errors) {
|
|
4283
|
+
const parentEdges = /* @__PURE__ */ new Map();
|
|
4284
|
+
const resolvingParents = /* @__PURE__ */ new Set();
|
|
4285
|
+
const resolveParent = (t) => {
|
|
4286
|
+
if (t.scope.kind !== "policy" || t.scope.policy.kind !== "parent") return 0;
|
|
4287
|
+
if (parentEdges.has(t.name)) return parentEdges.get(t.name);
|
|
4288
|
+
const pending = t.scope.policy;
|
|
4289
|
+
const at = parentScopeLines.get(pending);
|
|
4290
|
+
const where = at === void 0 ? "" : `line ${at}: `;
|
|
4291
|
+
if (resolvingParents.has(t.name)) {
|
|
4292
|
+
errors.push(`${where}Table "${t.name}" uses parent({ via: '${pending.via}' }), but its parent chain leads back to "${t.name}". Parent chains cannot form a cycle.`);
|
|
4293
|
+
parentEdges.set(t.name, null);
|
|
4294
|
+
return null;
|
|
4295
|
+
}
|
|
4296
|
+
resolvingParents.add(t.name);
|
|
4297
|
+
const fail = (message) => {
|
|
4298
|
+
errors.push(where + message);
|
|
4299
|
+
parentEdges.set(t.name, null);
|
|
4300
|
+
resolvingParents.delete(t.name);
|
|
4301
|
+
return null;
|
|
4302
|
+
};
|
|
4303
|
+
const fk = t.columns.find((column) => column.name === pending.via);
|
|
4304
|
+
if (!fk || !fk.references) {
|
|
4305
|
+
return fail(`Table "${t.name}" uses parent({ via: '${pending.via}' }), but "${pending.via}" is not a declared scalar foreign key.`);
|
|
4306
|
+
}
|
|
4307
|
+
const parentTable = tableByName.get(fk.references);
|
|
4308
|
+
const parentKeys = parentTable ? parentTable.columns.filter((column) => column.helper === "id") : [];
|
|
4309
|
+
const parentKey = parentKeys.length === 1 ? parentKeys[0] : void 0;
|
|
4310
|
+
let inherited = null;
|
|
4311
|
+
let edges = 1;
|
|
4312
|
+
if (parentTable && parentTable.scope.kind === "policy" && parentTable.scope.policy.kind === "parent") {
|
|
4313
|
+
const above = resolveParent(parentTable);
|
|
4314
|
+
if (above === null) {
|
|
4315
|
+
if (parentEdges.get(t.name) !== null) parentEdges.set(t.name, null);
|
|
4316
|
+
resolvingParents.delete(t.name);
|
|
4317
|
+
return null;
|
|
4318
|
+
}
|
|
4319
|
+
inherited = parentTable.scope.policy;
|
|
4320
|
+
edges = above + 1;
|
|
4321
|
+
} else if (parentTable) {
|
|
4322
|
+
inherited = privatePolicy(parentTable.scope);
|
|
4323
|
+
}
|
|
4324
|
+
if (!parentTable || !parentKey || !inherited) {
|
|
4325
|
+
return fail(`Table "${t.name}" uses parent({ via: '${pending.via}' }), but its referenced parent must have exactly one id() key and a direct owner(), group(), appRole(), member(), anyOf(owner(), member()) or parent() private scope.`);
|
|
4326
|
+
}
|
|
4327
|
+
if (edges > MAX_PARENT_EDGES2) {
|
|
4328
|
+
return fail(`Table "${t.name}" uses parent({ via: '${pending.via}' }), which makes a parent chain of ${edges} edges. At most ${MAX_PARENT_EDGES2} are allowed; inherit from a table nearer the root of the chain.`);
|
|
4329
|
+
}
|
|
4330
|
+
const compatible = fk.helper === parentKey.helper && (fk.helper !== "id" || fk.uuid === parentKey.uuid) || fk.helper === "integer" && parentKey.helper === "id" && !parentKey.uuid || fk.helper === "text" && parentKey.helper === "id" && parentKey.uuid;
|
|
4331
|
+
if (!compatible) {
|
|
4332
|
+
return fail(`Table "${t.name}" uses parent({ via: '${pending.via}' }), but that foreign key does not have the same scalar type as "${parentTable.name}"."${parentKey.name}".`);
|
|
4333
|
+
}
|
|
4334
|
+
t.scope.policy = { kind: "parent", via: pending.via, table: parentTable.name, parentKey: parentKey.name, policy: inherited };
|
|
4335
|
+
if (t.client?.create && !t.client.create.includes(pending.via)) {
|
|
4336
|
+
errors.push(`Table "${t.name}" uses parent({ via: '${pending.via}' }) and client.create must include "${pending.via}" so every new row explicitly selects an authorized parent.`);
|
|
4337
|
+
}
|
|
4338
|
+
resolvingParents.delete(t.name);
|
|
4339
|
+
parentEdges.set(t.name, edges);
|
|
4340
|
+
return edges;
|
|
4341
|
+
};
|
|
4342
|
+
for (const t of tables) resolveParent(t);
|
|
4343
|
+
}
|
|
4344
|
+
function policyEdges(policy) {
|
|
4345
|
+
return policy.kind === "parent" ? 1 + policyEdges(policy.policy) : 0;
|
|
4346
|
+
}
|
|
4347
|
+
function fileParentTarget(t) {
|
|
4348
|
+
const keys = t.columns.filter((column) => column.helper === "id");
|
|
4349
|
+
const key = keys.length === 1 ? keys[0] : null;
|
|
4350
|
+
const scope = t.scope;
|
|
4351
|
+
const privateUserPolicy = scope.kind === "owner" && !scope.visitors || scope.kind === "member" || scope.kind === "policy" && ["owner", "member", "any_of", "parent", "group", "app_role"].includes(scope.policy.kind);
|
|
4352
|
+
return {
|
|
4353
|
+
key: key ? key.name : null,
|
|
4354
|
+
keyType: key?.uuid ? "text" : "integer",
|
|
4355
|
+
privateUserPolicy,
|
|
4356
|
+
edges: scope.kind === "policy" ? policyEdges(scope.policy) : 0
|
|
4357
|
+
};
|
|
4358
|
+
}
|
|
4359
|
+
|
|
3780
4360
|
// worker/src/utils/db-schema-deploy/extract-schema-relations.ts
|
|
3781
4361
|
function validateDeclaredRelations(tables, tableByName, knownColumns, markedGone, errors) {
|
|
3782
4362
|
const columnByName = (t, col) => t.columns.find((c) => c.name === col);
|
|
@@ -3938,11 +4518,37 @@ function readFileOwner(r, fail, name) {
|
|
|
3938
4518
|
}
|
|
3939
4519
|
fail(key.line, `owner() on file collection "${name}" takes no options \u2014 the owner is always the signed-in uploader.`);
|
|
3940
4520
|
}
|
|
4521
|
+
function readFileParent(r, fail, name, line) {
|
|
4522
|
+
r.expectPunct("(", 'after "parent"');
|
|
4523
|
+
r.expectPunct("{", `in parent() for file collection "${name}" \u2014 parent() needs { table, via }`);
|
|
4524
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4525
|
+
while (!r.tryPunct("}")) {
|
|
4526
|
+
const key = r.readKey(`for a parent() option of file collection "${name}"`);
|
|
4527
|
+
r.expectPunct(":", `after "${key.name}" in parent() for file collection "${name}"`);
|
|
4528
|
+
if (key.name !== "table" && key.name !== "via") {
|
|
4529
|
+
fail(key.line, `parent() on file collection "${name}" has an unknown option "${key.name}". Allowed: table, via.`);
|
|
4530
|
+
}
|
|
4531
|
+
const t = r.next();
|
|
4532
|
+
if (t.kind !== "string") fail(t.line, `parent({ ${key.name} }) on file collection "${name}" must be one quoted name.`);
|
|
4533
|
+
if (!COLUMN_NAME.test(t.value)) fail(t.line, `parent({ ${key.name} }) on file collection "${name}" names an invalid ${key.name === "table" ? "table" : "field"} "${t.value}".`);
|
|
4534
|
+
if (seen.has(key.name)) fail(key.line, `parent() on file collection "${name}" declares ${key.name} twice.`);
|
|
4535
|
+
seen.set(key.name, key.name === "table" ? t.value.toLowerCase() : t.value);
|
|
4536
|
+
if (!r.tryPunct(",")) {
|
|
4537
|
+
r.expectPunct("}", `closing parent() for file collection "${name}"`);
|
|
4538
|
+
break;
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
r.tryPunct(",");
|
|
4542
|
+
r.expectPunct(")", "closing parent()");
|
|
4543
|
+
const missing = ["table", "via"].filter((k) => !seen.has(k));
|
|
4544
|
+
if (missing.length > 0) {
|
|
4545
|
+
fail(line, `parent() on file collection "${name}" is missing ${missing.join(" and ")}. It needs parent({ table: 'expenses', via: 'expenseId' }).`);
|
|
4546
|
+
}
|
|
4547
|
+
return { table: seen.get("table"), via: seen.get("via"), key: "", keyType: "integer" };
|
|
4548
|
+
}
|
|
3941
4549
|
function readFileScope(r, fail, name) {
|
|
3942
4550
|
const tok = r.expectIdent(`for the scope of file collection "${name}"`);
|
|
3943
|
-
if (tok.value === "parent") {
|
|
3944
|
-
fail(tok.line, `parent() is not available for file collections yet. Use owner(), member(), anyOf(owner(), member()), shared(), or serverOnly().`);
|
|
3945
|
-
}
|
|
4551
|
+
if (tok.value === "parent") return { kind: "parent", parent: readFileParent(r, fail, name, tok.line) };
|
|
3946
4552
|
if (tok.value === "owner") {
|
|
3947
4553
|
readFileOwner(r, fail, name);
|
|
3948
4554
|
return { kind: "owner" };
|
|
@@ -3971,7 +4577,7 @@ function readFileScope(r, fail, name) {
|
|
|
3971
4577
|
if (owners !== 1 || !member) fail(tok.line, `anyOf() on file collection "${name}" requires exactly one owner() and one member().`);
|
|
3972
4578
|
return { kind: "owner_or_member", member };
|
|
3973
4579
|
}
|
|
3974
|
-
return fail(tok.line, `unknown scope "${tok.value}" on file collection "${name}". Use owner(), member(), anyOf(owner(), member()), shared(), or serverOnly().`);
|
|
4580
|
+
return fail(tok.line, `unknown scope "${tok.value}" on file collection "${name}". Use owner(), member(), anyOf(owner(), member()), parent({ table, via }), shared(), or serverOnly().`);
|
|
3975
4581
|
}
|
|
3976
4582
|
function readCollection(r, fail, name, errors) {
|
|
3977
4583
|
const callee = r.expectIdent(`for file collection "${name}"`);
|
|
@@ -4045,7 +4651,7 @@ function readCollection(r, fail, name, errors) {
|
|
|
4045
4651
|
}
|
|
4046
4652
|
r.tryPunct(",");
|
|
4047
4653
|
r.expectPunct(")", "closing files()");
|
|
4048
|
-
if (!scope) return fail(callee.line, `files() for "${name}" needs a scope: owner(), member(), anyOf(owner(), member()), shared(), or serverOnly().`);
|
|
4654
|
+
if (!scope) return fail(callee.line, `files() for "${name}" needs a scope: owner(), member(), anyOf(owner(), member()), parent({ table, via }), shared(), or serverOnly().`);
|
|
4049
4655
|
const rootError = fileCollectionRootError(name, path);
|
|
4050
4656
|
if (rootError) errors.push(rootError);
|
|
4051
4657
|
const declaresClient = FILE_OPERATIONS.some((op) => client[op]);
|
|
@@ -4058,8 +4664,12 @@ function readCollection(r, fail, name, errors) {
|
|
|
4058
4664
|
if (isPublic && (scope.kind === "member" || scope.kind === "owner_or_member")) {
|
|
4059
4665
|
errors.push(`File collection "${name}" is public: true on a member() scope, which would let anyone read team files. Remove public, or use owner() or serverOnly().`);
|
|
4060
4666
|
}
|
|
4667
|
+
if (isPublic && scope.kind === "parent") {
|
|
4668
|
+
errors.push(`File collection "${name}" is public: true on a parent() scope, which would let anyone read files that follow a private row. Remove public.`);
|
|
4669
|
+
}
|
|
4061
4670
|
return { name, path: typeof path === "string" ? path : "", scope, public: isPublic, client, limits };
|
|
4062
4671
|
}
|
|
4672
|
+
var MAX_FILE_PARENT_EDGES = 4;
|
|
4063
4673
|
function readFileCollections(r, fail, errors) {
|
|
4064
4674
|
r.expectPunct("{", "opening schema option files");
|
|
4065
4675
|
const out = [];
|
|
@@ -4086,6 +4696,30 @@ function validateFileCollections(collections, table, errors) {
|
|
|
4086
4696
|
if (a.path === b.path && a.name < b.name) errors.push(`File collections "${a.name}" and "${b.name}" both use the path "${a.path}". Each collection needs its own folder.`);
|
|
4087
4697
|
else if (b.path.startsWith(a.path + "/")) errors.push(`File collection "${b.name}" (${b.path}) is nested inside "${a.name}" (${a.path}). Collection folders cannot overlap.`);
|
|
4088
4698
|
}
|
|
4699
|
+
if (a.scope.kind === "parent") {
|
|
4700
|
+
const parent = a.scope.parent;
|
|
4701
|
+
const target = table(parent.table);
|
|
4702
|
+
const facts = target?.parentTarget;
|
|
4703
|
+
if (!target || !facts) {
|
|
4704
|
+
errors.push(`File collection "${a.name}" uses parent({ table: '${parent.table}' }), which is not a table declared in db/schema.ts.`);
|
|
4705
|
+
continue;
|
|
4706
|
+
}
|
|
4707
|
+
if (!facts.privateUserPolicy) {
|
|
4708
|
+
errors.push(`File collection "${a.name}" uses parent({ table: '${parent.table}' }), but that table's scope is not owner(), member(), anyOf(owner(), member()), group(), appRole() or parent(). Files can follow only rows a signed-in user holds.`);
|
|
4709
|
+
continue;
|
|
4710
|
+
}
|
|
4711
|
+
if (!facts.key) {
|
|
4712
|
+
errors.push(`File collection "${a.name}" uses parent({ table: '${parent.table}' }), but that table needs exactly one id() key for files to point at.`);
|
|
4713
|
+
continue;
|
|
4714
|
+
}
|
|
4715
|
+
if (facts.edges + 1 > MAX_FILE_PARENT_EDGES) {
|
|
4716
|
+
errors.push(`File collection "${a.name}" uses parent({ table: '${parent.table}' }), which makes a chain of ${facts.edges + 1} parent edges counting the file. At most ${MAX_FILE_PARENT_EDGES} are allowed; attach the files to a table nearer the root of the chain.`);
|
|
4717
|
+
continue;
|
|
4718
|
+
}
|
|
4719
|
+
parent.key = facts.key;
|
|
4720
|
+
parent.keyType = facts.keyType;
|
|
4721
|
+
continue;
|
|
4722
|
+
}
|
|
4089
4723
|
if (a.scope.kind !== "member" && a.scope.kind !== "owner_or_member") continue;
|
|
4090
4724
|
const m = a.scope.member;
|
|
4091
4725
|
const membership = table(m.membership);
|
|
@@ -4106,7 +4740,7 @@ function canonicalFileCollectionsJson(collections) {
|
|
|
4106
4740
|
collections: [...collections].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((c) => ({
|
|
4107
4741
|
name: c.name,
|
|
4108
4742
|
path: c.path,
|
|
4109
|
-
scope: c.scope.kind === "member" || c.scope.kind === "owner_or_member" ? {
|
|
4743
|
+
scope: c.scope.kind === "parent" ? { kind: "parent", parent: { table: c.scope.parent.table, via: c.scope.parent.via, key: c.scope.parent.key, keyType: c.scope.parent.keyType } } : c.scope.kind === "member" || c.scope.kind === "owner_or_member" ? {
|
|
4110
4744
|
kind: c.scope.kind,
|
|
4111
4745
|
member: {
|
|
4112
4746
|
group: c.scope.member.group,
|
|
@@ -4123,6 +4757,405 @@ function canonicalFileCollectionsJson(collections) {
|
|
|
4123
4757
|
});
|
|
4124
4758
|
}
|
|
4125
4759
|
|
|
4760
|
+
// worker/src/utils/managed-groups/policy.ts
|
|
4761
|
+
var BUILT_IN_GROUP_ROLES2 = ["owner", "admin", "member"];
|
|
4762
|
+
var ROLE_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
4763
|
+
function isPrototypeName(name) {
|
|
4764
|
+
return name === "prototype" || Object.prototype.hasOwnProperty.call(Object.prototype, name);
|
|
4765
|
+
}
|
|
4766
|
+
function roleVocabularyErrors(policy) {
|
|
4767
|
+
const errors = [];
|
|
4768
|
+
const dupes = (list, what) => {
|
|
4769
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4770
|
+
for (const name of list) {
|
|
4771
|
+
if (seen.has(name)) errors.push(`${what} lists "${name}" twice.`);
|
|
4772
|
+
seen.add(name);
|
|
4773
|
+
}
|
|
4774
|
+
};
|
|
4775
|
+
for (const [list, what] of [[policy.groupRoles, "Group role"], [policy.appRoles, "App role"]]) {
|
|
4776
|
+
for (const role of list) {
|
|
4777
|
+
if (!ROLE_NAME.test(role)) errors.push(`${what} "${role}" must be lowercase letters, digits and underscores, starting with a letter (at most 64).`);
|
|
4778
|
+
else if (isPrototypeName(role)) errors.push(`${what} "${role}" is a reserved name.`);
|
|
4779
|
+
}
|
|
4780
|
+
}
|
|
4781
|
+
dupes(policy.groupRoles, "groups.roles");
|
|
4782
|
+
dupes(policy.adminGrantableGroupRoles, "groups.adminGrantableRoles");
|
|
4783
|
+
dupes(policy.appRoles, "appRoles");
|
|
4784
|
+
for (const role of policy.groupRoles) {
|
|
4785
|
+
if (BUILT_IN_GROUP_ROLES2.includes(role)) errors.push(`"${role}" is a built-in group role; declare only additional roles.`);
|
|
4786
|
+
}
|
|
4787
|
+
for (const role of policy.adminGrantableGroupRoles) {
|
|
4788
|
+
if (!policy.groupRoles.includes(role)) errors.push(`groups.adminGrantableRoles names "${role}", which is not in groups.roles.`);
|
|
4789
|
+
}
|
|
4790
|
+
for (const role of Object.keys(policy.appRoleDelegation)) {
|
|
4791
|
+
const grantors = policy.appRoleDelegation[role];
|
|
4792
|
+
if (!policy.appRoles.includes(role)) errors.push(`App role delegation names undeclared app role "${role}".`);
|
|
4793
|
+
dupes(grantors, `appRoles.${role}.grantableBy`);
|
|
4794
|
+
for (const grantor of grantors) {
|
|
4795
|
+
if (!policy.appRoles.includes(grantor)) errors.push(`appRoles.${role}.grantableBy names "${grantor}", which is not a declared app role.`);
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
return errors;
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4801
|
+
// worker/src/utils/db-schema-deploy/extract-schema-groups.ts
|
|
4802
|
+
var GROUP_OPERATIONS = ["read", "create", "update", "delete"];
|
|
4803
|
+
var DEFAULT_GROUP_COLUMN = "group_id";
|
|
4804
|
+
var IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4805
|
+
var declaredGroupRoles = /* @__PURE__ */ new WeakMap();
|
|
4806
|
+
function readRoleList(r, fail, ctx) {
|
|
4807
|
+
const open = r.next();
|
|
4808
|
+
if (open.kind !== "punct" || open.value !== "[") fail(open.line, `${ctx} must be a list of quoted role names, such as ['owner', 'admin'].`);
|
|
4809
|
+
const out = [];
|
|
4810
|
+
while (!r.tryPunct("]")) {
|
|
4811
|
+
const t = r.next();
|
|
4812
|
+
if (t.kind !== "string") fail(t.line, `${ctx} must be a list of quoted role names; values must be literal strings.`);
|
|
4813
|
+
if (out.includes(t.value)) fail(t.line, `${ctx} lists "${t.value}" twice.`);
|
|
4814
|
+
out.push(t.value);
|
|
4815
|
+
if (!r.tryPunct(",")) {
|
|
4816
|
+
r.expectPunct("]", `closing ${ctx}`);
|
|
4817
|
+
break;
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
return out;
|
|
4821
|
+
}
|
|
4822
|
+
function readGroupScope(r, tableName, line, fail) {
|
|
4823
|
+
r.expectPunct("(", 'after "group"');
|
|
4824
|
+
let column = DEFAULT_GROUP_COLUMN;
|
|
4825
|
+
const explicit = {};
|
|
4826
|
+
if (!r.tryPunct(")")) {
|
|
4827
|
+
r.expectPunct("{", `in group() options for table "${tableName}"`);
|
|
4828
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4829
|
+
while (!r.tryPunct("}")) {
|
|
4830
|
+
const key = r.readKey(`for a group() option of table "${tableName}"`);
|
|
4831
|
+
if (seen.has(key.name)) fail(key.line, `group() on table "${tableName}" declares ${key.name} twice.`);
|
|
4832
|
+
seen.add(key.name);
|
|
4833
|
+
r.expectPunct(":", `after "${key.name}" in group() for table "${tableName}"`);
|
|
4834
|
+
if (key.name === "column") {
|
|
4835
|
+
const t = r.next();
|
|
4836
|
+
if (t.kind !== "string" || !IDENT.test(t.value) || t.value.length > 64) fail(t.line, `group({ column }) on table "${tableName}" must name a declared text column.`);
|
|
4837
|
+
column = t.value.toLowerCase();
|
|
4838
|
+
} else if (key.name === "roles") {
|
|
4839
|
+
r.expectPunct("{", `opening group({ roles }) on table "${tableName}"`);
|
|
4840
|
+
while (!r.tryPunct("}")) {
|
|
4841
|
+
const op = r.readKey(`for an operation in group({ roles }) on table "${tableName}"`);
|
|
4842
|
+
if (!GROUP_OPERATIONS.includes(op.name)) {
|
|
4843
|
+
fail(op.line, `group({ roles }) on table "${tableName}" has an unknown operation "${op.name}". Allowed: read, create, update, delete.`);
|
|
4844
|
+
}
|
|
4845
|
+
if (explicit[op.name]) fail(op.line, `group({ roles }) on table "${tableName}" declares ${op.name} twice.`);
|
|
4846
|
+
r.expectPunct(":", `after "${op.name}" in group({ roles }) on table "${tableName}"`);
|
|
4847
|
+
explicit[op.name] = readRoleList(r, fail, `group({ roles: { ${op.name} } }) on table "${tableName}"`);
|
|
4848
|
+
if (!r.tryPunct(",")) {
|
|
4849
|
+
r.expectPunct("}", `closing group({ roles }) on table "${tableName}"`);
|
|
4850
|
+
break;
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
} else {
|
|
4854
|
+
fail(key.line, `group() on table "${tableName}" has an unknown option "${key.name}". Allowed: column, roles.`);
|
|
4855
|
+
}
|
|
4856
|
+
if (!r.tryPunct(",")) {
|
|
4857
|
+
r.expectPunct("}", `closing group() options for table "${tableName}"`);
|
|
4858
|
+
break;
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
r.tryPunct(",");
|
|
4862
|
+
r.expectPunct(")", "closing group()");
|
|
4863
|
+
}
|
|
4864
|
+
const node = { kind: "group", column, roles: { read: [], create: [], update: [], delete: [] } };
|
|
4865
|
+
declaredGroupRoles.set(node, { line, explicit });
|
|
4866
|
+
return node;
|
|
4867
|
+
}
|
|
4868
|
+
var sorted = (list) => [...list].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
4869
|
+
function resolveGroupScopes(tables, vocabulary, errors) {
|
|
4870
|
+
const allRoles = [...BUILT_IN_GROUP_ROLES2, ...vocabulary?.roles ?? []];
|
|
4871
|
+
for (const t of tables) {
|
|
4872
|
+
if (t.scope.kind !== "policy" || t.scope.policy.kind !== "group") continue;
|
|
4873
|
+
const node = t.scope.policy;
|
|
4874
|
+
const declared = declaredGroupRoles.get(node);
|
|
4875
|
+
if (!declared) continue;
|
|
4876
|
+
const where = `line ${declared.line}: `;
|
|
4877
|
+
const column = t.columns.find((c) => c.name === node.column);
|
|
4878
|
+
if (!column) {
|
|
4879
|
+
errors.push(`${where}Table "${t.name}" uses group({ column: '${node.column}' }), but "${node.column}" is not one of its declared columns. Declare it as ${node.column}: text().`);
|
|
4880
|
+
} else if (column.helper !== "text" || column.nullable) {
|
|
4881
|
+
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
|
+
}
|
|
4883
|
+
for (const op of GROUP_OPERATIONS) {
|
|
4884
|
+
const list = declared.explicit[op] ?? allRoles;
|
|
4885
|
+
for (const role of list) {
|
|
4886
|
+
if (!allRoles.includes(role)) {
|
|
4887
|
+
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.`);
|
|
4888
|
+
}
|
|
4889
|
+
}
|
|
4890
|
+
node.roles[op] = sorted(list);
|
|
4891
|
+
}
|
|
4892
|
+
for (const op of ["update", "delete"]) {
|
|
4893
|
+
const missing = node.roles[op].filter((role) => !node.roles.read.includes(role));
|
|
4894
|
+
if (missing.length) {
|
|
4895
|
+
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
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
if (t.client?.create && !t.client.create.includes(node.column)) {
|
|
4899
|
+
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
|
+
}
|
|
4901
|
+
declaredGroupRoles.delete(node);
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
function groupLeafOf(t) {
|
|
4905
|
+
if (t.scope.kind !== "policy") return null;
|
|
4906
|
+
let policy = t.scope.policy;
|
|
4907
|
+
let inherited = false;
|
|
4908
|
+
while (policy.kind === "parent") {
|
|
4909
|
+
policy = policy.policy;
|
|
4910
|
+
inherited = true;
|
|
4911
|
+
}
|
|
4912
|
+
return policy.kind === "group" ? { node: policy, inherited } : null;
|
|
4913
|
+
}
|
|
4914
|
+
function resolveGroupReferences(tables, errors) {
|
|
4915
|
+
const byName2 = new Map(tables.map((t) => [t.name, t]));
|
|
4916
|
+
for (const t of tables) {
|
|
4917
|
+
const own = groupLeafOf(t);
|
|
4918
|
+
const refs = [];
|
|
4919
|
+
const via = t.scope.kind === "policy" && t.scope.policy.kind === "parent" ? t.scope.policy.via : null;
|
|
4920
|
+
for (const column of t.columns) {
|
|
4921
|
+
if (!column.references) continue;
|
|
4922
|
+
const target = byName2.get(column.references);
|
|
4923
|
+
if (!target) continue;
|
|
4924
|
+
const theirs = groupLeafOf(target);
|
|
4925
|
+
if (!own || !theirs || column.name === via) continue;
|
|
4926
|
+
const keys = target.columns.filter((c) => c.helper === "id");
|
|
4927
|
+
if (keys.length !== 1) {
|
|
4928
|
+
errors.push(`Table "${t.name}" references the group-owned table "${target.name}" through "${column.name}", which needs exactly one id() key on "${target.name}".`);
|
|
4929
|
+
continue;
|
|
4930
|
+
}
|
|
4931
|
+
if (own.inherited || theirs.inherited) continue;
|
|
4932
|
+
refs.push({ column: column.name, group: own.node.column, table: target.name, targetGroup: theirs.node.column, targetKey: keys[0].name });
|
|
4933
|
+
}
|
|
4934
|
+
if (refs.length > 0) t.groupRefs = refs.sort((a, b) => a.column < b.column ? -1 : a.column > b.column ? 1 : 0);
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
function resolveGroupsAndParents(tables, tableByName, privatePolicy, vocabulary, errors) {
|
|
4938
|
+
resolveGroupScopes(tables, vocabulary, errors);
|
|
4939
|
+
resolveParentChains(tables, tableByName, privatePolicy, errors);
|
|
4940
|
+
resolveGroupReferences(tables, errors);
|
|
4941
|
+
}
|
|
4942
|
+
|
|
4943
|
+
// worker/src/utils/db-schema-deploy/extract-schema-app-roles.ts
|
|
4944
|
+
var APP_ROLE_OPERATIONS = ["read", "create", "update", "delete"];
|
|
4945
|
+
var declaredLines = /* @__PURE__ */ new WeakMap();
|
|
4946
|
+
function readAppRoleScope(r, tableName, line, fail) {
|
|
4947
|
+
const where = `appRole() on table "${tableName}"`;
|
|
4948
|
+
r.expectPunct("(", 'after "appRole"');
|
|
4949
|
+
r.expectPunct("{", `in ${where} \u2014 it needs appRole({ roles: { read: [...], ... } })`);
|
|
4950
|
+
const node = { kind: "app_role", roles: { read: [], create: [], update: [], delete: [] } };
|
|
4951
|
+
const names = [];
|
|
4952
|
+
let sawRoles = false;
|
|
4953
|
+
while (!r.tryPunct("}")) {
|
|
4954
|
+
const key = r.readKey(`for an option of ${where}`);
|
|
4955
|
+
if (key.name !== "roles") fail(key.line, `${where} has an unknown option "${key.name}". Allowed: roles.`);
|
|
4956
|
+
if (sawRoles) fail(key.line, `${where} declares roles twice.`);
|
|
4957
|
+
sawRoles = true;
|
|
4958
|
+
r.expectPunct(":", `after "roles" in ${where}`);
|
|
4959
|
+
r.expectPunct("{", `opening appRole({ roles }) on table "${tableName}"`);
|
|
4960
|
+
const seenOps = /* @__PURE__ */ new Set();
|
|
4961
|
+
while (!r.tryPunct("}")) {
|
|
4962
|
+
const op = r.readKey(`for an operation in appRole({ roles }) on table "${tableName}"`);
|
|
4963
|
+
if (!APP_ROLE_OPERATIONS.includes(op.name)) {
|
|
4964
|
+
fail(op.line, `appRole({ roles }) on table "${tableName}" has an unknown operation "${op.name}". Allowed: read, create, update, delete.`);
|
|
4965
|
+
}
|
|
4966
|
+
if (seenOps.has(op.name)) fail(op.line, `appRole({ roles }) on table "${tableName}" declares ${op.name} twice.`);
|
|
4967
|
+
seenOps.add(op.name);
|
|
4968
|
+
r.expectPunct(":", `after "${op.name}" in appRole({ roles }) on table "${tableName}"`);
|
|
4969
|
+
const ctx = `appRole({ roles: { ${op.name} } }) on table "${tableName}"`;
|
|
4970
|
+
const open = r.next();
|
|
4971
|
+
if (open.kind !== "punct" || open.value !== "[") fail(open.line, `${ctx} must be a list of quoted app role names, such as ['staff'].`);
|
|
4972
|
+
const list = [];
|
|
4973
|
+
while (!r.tryPunct("]")) {
|
|
4974
|
+
const t = r.next();
|
|
4975
|
+
if (t.kind !== "string") fail(t.line, `${ctx} must be a list of quoted app role names; values must be literal strings.`);
|
|
4976
|
+
if (list.includes(t.value)) fail(t.line, `${ctx} lists "${t.value}" twice.`);
|
|
4977
|
+
list.push(t.value);
|
|
4978
|
+
names.push({ op: op.name, name: t.value, line: t.line });
|
|
4979
|
+
if (!r.tryPunct(",")) {
|
|
4980
|
+
r.expectPunct("]", `closing ${ctx}`);
|
|
4981
|
+
break;
|
|
4982
|
+
}
|
|
4983
|
+
}
|
|
4984
|
+
node.roles[op.name] = [...list].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
4985
|
+
if (!r.tryPunct(",")) {
|
|
4986
|
+
r.expectPunct("}", `closing appRole({ roles }) on table "${tableName}"`);
|
|
4987
|
+
break;
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
if (!r.tryPunct(",")) {
|
|
4991
|
+
r.expectPunct("}", `closing ${where}`);
|
|
4992
|
+
break;
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
r.tryPunct(",");
|
|
4996
|
+
r.expectPunct(")", "closing appRole()");
|
|
4997
|
+
if (!sawRoles) fail(line, `${where} needs roles: appRole({ roles: { read: [...], ... } }). An omitted operation allows nobody.`);
|
|
4998
|
+
declaredLines.set(node, { line, names });
|
|
4999
|
+
return node;
|
|
5000
|
+
}
|
|
5001
|
+
function resolveAppRoleScopes(tables, appRoles, errors) {
|
|
5002
|
+
const vocabulary = new Set((appRoles ?? []).map((role) => role.name));
|
|
5003
|
+
for (const t of tables) {
|
|
5004
|
+
if (t.scope.kind !== "policy" || t.scope.policy.kind !== "app_role") continue;
|
|
5005
|
+
const node = t.scope.policy;
|
|
5006
|
+
const declared = declaredLines.get(node);
|
|
5007
|
+
if (!declared) continue;
|
|
5008
|
+
for (const { op, name, line } of declared.names) {
|
|
5009
|
+
if (!vocabulary.has(name)) {
|
|
5010
|
+
errors.push(`line ${line}: appRole({ roles: { ${op} } }) on table "${t.name}" names "${name}", which is not declared in the schema's appRoles option. List only app roles declared there; group roles and admin do not apply.`);
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
for (const op of ["update", "delete"]) {
|
|
5014
|
+
const missing = node.roles[op].filter((role) => !node.roles.read.includes(role));
|
|
5015
|
+
if (missing.length) {
|
|
5016
|
+
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
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
declaredLines.delete(node);
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
// worker/src/utils/db-schema-deploy/extract-schema-roles.ts
|
|
5024
|
+
var GROUP_KEYS = ["roles", "adminGrantableRoles"];
|
|
5025
|
+
var APP_ROLE_KEYS = ["grantableBy"];
|
|
5026
|
+
function readStringList2(r, fail, ctx) {
|
|
5027
|
+
const open = r.peek();
|
|
5028
|
+
if (open.kind !== "punct" || open.value !== "[") fail(open.line, `${ctx} must be a list of quoted role names, such as ['viewer'].`);
|
|
5029
|
+
r.next();
|
|
5030
|
+
const out = [];
|
|
5031
|
+
while (!r.tryPunct("]")) {
|
|
5032
|
+
const t = r.next();
|
|
5033
|
+
if (t.kind !== "string") fail(t.line, `${ctx} must be a list of quoted role names; found ${t.kind === "eof" ? "the end of the file" : `"${t.value}"`}. Values must be literal strings.`);
|
|
5034
|
+
out.push(t.value);
|
|
5035
|
+
if (!r.tryPunct(",")) {
|
|
5036
|
+
r.expectPunct("]", `closing ${ctx}`);
|
|
5037
|
+
break;
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
return out;
|
|
5041
|
+
}
|
|
5042
|
+
function readGroupRoles(r, fail) {
|
|
5043
|
+
r.expectPunct("{", "opening schema option groups");
|
|
5044
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5045
|
+
const out = { roles: [], adminGrantableRoles: [] };
|
|
5046
|
+
while (!r.tryPunct("}")) {
|
|
5047
|
+
const key = r.readKey("for a groups option");
|
|
5048
|
+
if (!GROUP_KEYS.includes(key.name)) {
|
|
5049
|
+
fail(key.line, `groups has an unknown option "${key.name}". Allowed: ${GROUP_KEYS.join(", ")}. owner, admin and member are built in.`);
|
|
5050
|
+
}
|
|
5051
|
+
if (seen.has(key.name)) fail(key.line, `groups declares ${key.name} twice.`);
|
|
5052
|
+
seen.add(key.name);
|
|
5053
|
+
r.expectPunct(":", `after groups.${key.name}`);
|
|
5054
|
+
out[key.name] = readStringList2(r, fail, `groups.${key.name}`);
|
|
5055
|
+
if (!r.tryPunct(",")) {
|
|
5056
|
+
r.expectPunct("}", "closing schema option groups");
|
|
5057
|
+
break;
|
|
5058
|
+
}
|
|
5059
|
+
}
|
|
5060
|
+
return out;
|
|
5061
|
+
}
|
|
5062
|
+
function readAppRoles(r, fail) {
|
|
5063
|
+
r.expectPunct("{", "opening schema option appRoles");
|
|
5064
|
+
const out = [];
|
|
5065
|
+
while (!r.tryPunct("}")) {
|
|
5066
|
+
const key = r.readKey("for an app role name");
|
|
5067
|
+
if (out.some((role2) => role2.name === key.name)) fail(key.line, `app role "${key.name}" is declared twice.`);
|
|
5068
|
+
r.expectPunct(":", `after app role "${key.name}"`);
|
|
5069
|
+
r.expectPunct("{", `opening app role "${key.name}" \u2014 declare it as { grantableBy: [...] }`);
|
|
5070
|
+
const role = { name: key.name, grantableBy: [] };
|
|
5071
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5072
|
+
while (!r.tryPunct("}")) {
|
|
5073
|
+
const opt = r.readKey(`for an option of app role "${key.name}"`);
|
|
5074
|
+
if (!APP_ROLE_KEYS.includes(opt.name)) {
|
|
5075
|
+
fail(opt.line, `app role "${key.name}" has an unknown option "${opt.name}". Allowed: ${APP_ROLE_KEYS.join(", ")}.`);
|
|
5076
|
+
}
|
|
5077
|
+
if (seen.has(opt.name)) fail(opt.line, `app role "${key.name}" declares ${opt.name} twice.`);
|
|
5078
|
+
seen.add(opt.name);
|
|
5079
|
+
r.expectPunct(":", `after appRoles.${key.name}.${opt.name}`);
|
|
5080
|
+
role.grantableBy = readStringList2(r, fail, `appRoles.${key.name}.grantableBy`);
|
|
5081
|
+
if (!r.tryPunct(",")) {
|
|
5082
|
+
r.expectPunct("}", `closing app role "${key.name}"`);
|
|
5083
|
+
break;
|
|
5084
|
+
}
|
|
5085
|
+
}
|
|
5086
|
+
out.push(role);
|
|
5087
|
+
if (!r.tryPunct(",")) {
|
|
5088
|
+
r.expectPunct("}", "closing schema option appRoles");
|
|
5089
|
+
break;
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
return out.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
5093
|
+
}
|
|
5094
|
+
var sorted2 = (list) => [...list].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
5095
|
+
function canonicalRoleVocabulary(groups, appRoles) {
|
|
5096
|
+
return {
|
|
5097
|
+
v: 1,
|
|
5098
|
+
groupRoles: sorted2(groups?.roles ?? []),
|
|
5099
|
+
adminGrantableGroupRoles: sorted2(groups?.adminGrantableRoles ?? []),
|
|
5100
|
+
appRoles: [...appRoles ?? []].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((role) => ({ name: role.name, grantableBy: sorted2(role.grantableBy) }))
|
|
5101
|
+
};
|
|
5102
|
+
}
|
|
5103
|
+
function validateDeclaredRoles(groups, appRoles) {
|
|
5104
|
+
const delegation = /* @__PURE__ */ Object.create(null);
|
|
5105
|
+
for (const role of appRoles ?? []) delegation[role.name] = role.grantableBy;
|
|
5106
|
+
return roleVocabularyErrors({
|
|
5107
|
+
groupRoles: groups?.roles ?? [],
|
|
5108
|
+
adminGrantableGroupRoles: groups?.adminGrantableRoles ?? [],
|
|
5109
|
+
appRoles: (appRoles ?? []).map((role) => role.name),
|
|
5110
|
+
appRoleDelegation: delegation
|
|
5111
|
+
});
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
// worker/src/utils/db-schema-deploy/extract-schema-options.ts
|
|
5115
|
+
function readSchemaOptions(reader, fail, fileErrors) {
|
|
5116
|
+
const o = {};
|
|
5117
|
+
if (!reader.tryPunct(",") || reader.peek().kind === "punct" && reader.peek().value === ")") return o;
|
|
5118
|
+
reader.expectPunct("{", "opening schema options");
|
|
5119
|
+
while (!reader.tryPunct("}")) {
|
|
5120
|
+
const option = reader.readKey("for a schema option");
|
|
5121
|
+
const after = () => reader.expectPunct(":", `after schema option "${option.name}"`);
|
|
5122
|
+
if (option.name === "files" && !o.fileCollections) {
|
|
5123
|
+
after();
|
|
5124
|
+
o.fileCollections = readFileCollections(reader, fail, fileErrors);
|
|
5125
|
+
} else if (option.name === "groups" && !o.groupRoles) {
|
|
5126
|
+
after();
|
|
5127
|
+
o.groupRoles = readGroupRoles(reader, fail);
|
|
5128
|
+
} else if (option.name === "appRoles" && !o.appRoles) {
|
|
5129
|
+
after();
|
|
5130
|
+
o.appRoles = readAppRoles(reader, fail);
|
|
5131
|
+
} else if (option.name === "payments" && !o.payments) {
|
|
5132
|
+
after();
|
|
5133
|
+
o.payments = readPaymentBindings(reader, fail);
|
|
5134
|
+
} else if (option.name === "jobs" && !o.jobs) {
|
|
5135
|
+
after();
|
|
5136
|
+
o.jobs = readJobBindings(reader, fail);
|
|
5137
|
+
} else if (option.name === "serviceRoles" && !o.serviceRoles) {
|
|
5138
|
+
after();
|
|
5139
|
+
o.serviceRoles = readServiceRoles(reader, fail);
|
|
5140
|
+
} else if (option.name === "search" && !o.managedSearch) {
|
|
5141
|
+
after();
|
|
5142
|
+
const helper = reader.expectIdent('for schema option "search"');
|
|
5143
|
+
if (helper.value !== "owner") fail(helper.line, "search ownership must be declared with owner().");
|
|
5144
|
+
reader.expectPunct("(", "after search: owner");
|
|
5145
|
+
reader.expectPunct(")", "closing search: owner()");
|
|
5146
|
+
o.managedSearch = { kind: "owner" };
|
|
5147
|
+
} else {
|
|
5148
|
+
fail(option.line, `unknown schema option "${option.name}". The schema-level options are search: owner(), files: { \u2026 }, groups: { \u2026 }, appRoles: { \u2026 }, payments: { \u2026 }, jobs: { \u2026 } and serviceRoles: { \u2026 }, each at most once.`);
|
|
5149
|
+
}
|
|
5150
|
+
if (!reader.tryPunct(",")) {
|
|
5151
|
+
reader.expectPunct("}", "closing schema options");
|
|
5152
|
+
break;
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
reader.tryPunct(",");
|
|
5156
|
+
return o;
|
|
5157
|
+
}
|
|
5158
|
+
|
|
4126
5159
|
// worker/src/utils/db-schema-deploy/extract-schema-ts.ts
|
|
4127
5160
|
function policyOwner(policy) {
|
|
4128
5161
|
if (policy.kind === "owner") return policy;
|
|
@@ -4138,6 +5171,13 @@ function declaredOwnerColumn(scope) {
|
|
|
4138
5171
|
function declaredScopeIntent(scope) {
|
|
4139
5172
|
return scope.kind === "owner" ? "scoped" : scope.kind;
|
|
4140
5173
|
}
|
|
5174
|
+
function declaredScopeIdentityColumns(scope) {
|
|
5175
|
+
if (scope.kind === "owner") return [scope.column];
|
|
5176
|
+
if (scope.kind === "member") return [...scope.group];
|
|
5177
|
+
if (scope.kind !== "policy") return [];
|
|
5178
|
+
const collect = (policy) => policy.kind === "owner" ? [policy.column] : policy.kind === "member" ? [...policy.group] : policy.kind === "any_of" ? policy.policies.flatMap(collect) : policy.kind === "group" ? [policy.column] : policy.kind === "app_role" ? [] : [policy.via];
|
|
5179
|
+
return [...new Set(collect(scope.policy))];
|
|
5180
|
+
}
|
|
4141
5181
|
var COLUMN_HELPERS = /* @__PURE__ */ new Set([
|
|
4142
5182
|
"id",
|
|
4143
5183
|
"text",
|
|
@@ -4149,7 +5189,7 @@ var COLUMN_HELPERS = /* @__PURE__ */ new Set([
|
|
|
4149
5189
|
"json",
|
|
4150
5190
|
"blob"
|
|
4151
5191
|
]);
|
|
4152
|
-
var SCOPE_HELPERS = /* @__PURE__ */ new Set(["owner", "shared", "serverOnly", "member", "anyOf", "parent"]);
|
|
5192
|
+
var SCOPE_HELPERS = /* @__PURE__ */ new Set(["owner", "shared", "serverOnly", "member", "anyOf", "parent", "group", "appRole"]);
|
|
4153
5193
|
var TABLE_MARKER_HELPERS = /* @__PURE__ */ new Set(["removedTable", "exported"]);
|
|
4154
5194
|
var SCHEMA_CALLABLE = /* @__PURE__ */ new Set([
|
|
4155
5195
|
...COLUMN_HELPERS,
|
|
@@ -4703,7 +5743,9 @@ function readParentScope(r, tableName, line) {
|
|
|
4703
5743
|
if (typeof value !== "string" || !SAFE_IDENT.test(value) || value.length > MAX_NAME_LENGTH) {
|
|
4704
5744
|
throw new SchemaTsError(`line ${opts.get("via").line}: parent({ via }) on table "${tableName}" must name a valid declared foreign-key column.`);
|
|
4705
5745
|
}
|
|
4706
|
-
|
|
5746
|
+
const sentinel = { kind: "parent", via: value.toLowerCase(), table: "", parentKey: "", policy: { kind: "owner", column: "" } };
|
|
5747
|
+
rememberParentLine(sentinel, line);
|
|
5748
|
+
return { kind: "policy", policy: sentinel };
|
|
4707
5749
|
}
|
|
4708
5750
|
function readRelations(r, tableName) {
|
|
4709
5751
|
r.expectPunct("{", `for "relations" of table "${tableName}"`);
|
|
@@ -4753,11 +5795,16 @@ function readScope(r, tableName) {
|
|
|
4753
5795
|
const tok = r.expectIdent(`for the scope of table "${tableName}"`);
|
|
4754
5796
|
if (!SCOPE_HELPERS.has(tok.value)) {
|
|
4755
5797
|
throw new SchemaTsError(
|
|
4756
|
-
`line ${tok.line}: unknown scope "${tok.value}" on table "${tableName}". Use owner(), member(), anyOf(), parent(), shared(), or serverOnly().`
|
|
5798
|
+
`line ${tok.line}: unknown scope "${tok.value}" on table "${tableName}". Use owner(), group(), appRole(), member(), anyOf(), parent(), shared(), or serverOnly().`
|
|
4757
5799
|
);
|
|
4758
5800
|
}
|
|
4759
5801
|
if (tok.value === "anyOf") return readAnyOfScope(r, tableName, tok.line);
|
|
4760
5802
|
if (tok.value === "parent") return readParentScope(r, tableName, tok.line);
|
|
5803
|
+
const failAt = (line, message) => {
|
|
5804
|
+
throw new SchemaTsError(`line ${line}: ${message}`);
|
|
5805
|
+
};
|
|
5806
|
+
if (tok.value === "group") return { kind: "policy", policy: readGroupScope(r, tableName, tok.line, failAt) };
|
|
5807
|
+
if (tok.value === "appRole") return { kind: "policy", policy: readAppRoleScope(r, tableName, tok.line, failAt) };
|
|
4761
5808
|
if (tok.value === "member") {
|
|
4762
5809
|
r.expectPunct("(", 'after "member"');
|
|
4763
5810
|
const scope = readMemberScope(r, tableName, tok.line);
|
|
@@ -5077,37 +6124,11 @@ function extractSchemaTs(source) {
|
|
|
5077
6124
|
break;
|
|
5078
6125
|
}
|
|
5079
6126
|
}
|
|
5080
|
-
let managedSearch;
|
|
5081
|
-
let fileCollections;
|
|
5082
6127
|
const fileErrors = [];
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
if (option.name === "files" && !fileCollections) {
|
|
5088
|
-
reader.expectPunct(":", 'after schema option "files"');
|
|
5089
|
-
fileCollections = readFileCollections(reader, (line, message) => {
|
|
5090
|
-
throw new SchemaTsError(`line ${line}: ${message}`);
|
|
5091
|
-
}, fileErrors);
|
|
5092
|
-
} else if (option.name === "search" && !managedSearch) {
|
|
5093
|
-
reader.expectPunct(":", 'after schema option "search"');
|
|
5094
|
-
const helper = reader.expectIdent('for schema option "search"');
|
|
5095
|
-
if (helper.value !== "owner") {
|
|
5096
|
-
throw new SchemaTsError(`line ${helper.line}: search ownership must be declared with owner().`);
|
|
5097
|
-
}
|
|
5098
|
-
reader.expectPunct("(", "after search: owner");
|
|
5099
|
-
reader.expectPunct(")", "closing search: owner()");
|
|
5100
|
-
managedSearch = { kind: "owner" };
|
|
5101
|
-
} else {
|
|
5102
|
-
throw new SchemaTsError(`line ${option.line}: unknown schema option "${option.name}". The schema-level options are search: owner() and files: { \u2026 }, each at most once.`);
|
|
5103
|
-
}
|
|
5104
|
-
if (!reader.tryPunct(",")) {
|
|
5105
|
-
reader.expectPunct("}", "closing schema options");
|
|
5106
|
-
break;
|
|
5107
|
-
}
|
|
5108
|
-
}
|
|
5109
|
-
reader.tryPunct(",");
|
|
5110
|
-
}
|
|
6128
|
+
const fail = (line, message) => {
|
|
6129
|
+
throw new SchemaTsError(`line ${line}: ${message}`);
|
|
6130
|
+
};
|
|
6131
|
+
const { managedSearch, fileCollections, groupRoles, appRoles, payments, jobs, serviceRoles } = readSchemaOptions(reader, fail, fileErrors);
|
|
5111
6132
|
reader.expectPunct(")", "closing schema(\u2026)");
|
|
5112
6133
|
reader.tryPunct(";");
|
|
5113
6134
|
if (!reader.atEof()) {
|
|
@@ -5146,31 +6167,8 @@ function extractSchemaTs(source) {
|
|
|
5146
6167
|
if (scope.kind === "policy" && scope.policy.kind !== "parent") return scope.policy;
|
|
5147
6168
|
return null;
|
|
5148
6169
|
};
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
const pending = t.scope.policy;
|
|
5152
|
-
const fk = t.columns.find((column) => column.name === pending.via);
|
|
5153
|
-
if (!fk || !fk.references) {
|
|
5154
|
-
errors.push(`Table "${t.name}" uses parent({ via: '${pending.via}' }), but "${pending.via}" is not a declared scalar foreign key.`);
|
|
5155
|
-
continue;
|
|
5156
|
-
}
|
|
5157
|
-
const parentTable = tableByName.get(fk.references);
|
|
5158
|
-
const parentKey = parentTable?.columns.find((column) => column.helper === "id");
|
|
5159
|
-
const inherited = parentTable ? privatePolicy(parentTable.scope) : null;
|
|
5160
|
-
if (!parentTable || !parentKey || !inherited) {
|
|
5161
|
-
errors.push(`Table "${t.name}" uses parent({ via: '${pending.via}' }), but its referenced parent must have one id() key and a direct owner(), member(), or anyOf(owner(), member()) private scope.`);
|
|
5162
|
-
continue;
|
|
5163
|
-
}
|
|
5164
|
-
const compatible = fk.helper === parentKey.helper && (fk.helper !== "id" || fk.uuid === parentKey.uuid) || fk.helper === "integer" && parentKey.helper === "id" && !parentKey.uuid || fk.helper === "text" && parentKey.helper === "id" && parentKey.uuid;
|
|
5165
|
-
if (!compatible) {
|
|
5166
|
-
errors.push(`Table "${t.name}" uses parent({ via: '${pending.via}' }), but that foreign key does not have the same scalar type as "${parentTable.name}"."${parentKey.name}".`);
|
|
5167
|
-
continue;
|
|
5168
|
-
}
|
|
5169
|
-
t.scope.policy = { kind: "parent", via: pending.via, table: parentTable.name, parentKey: parentKey.name, policy: inherited };
|
|
5170
|
-
if (t.client?.create && !t.client.create.includes(pending.via)) {
|
|
5171
|
-
errors.push(`Table "${t.name}" uses parent({ via: '${pending.via}' }) and client.create must include "${pending.via}" so every new row explicitly selects an authorized parent.`);
|
|
5172
|
-
}
|
|
5173
|
-
}
|
|
6170
|
+
resolveAppRoleScopes(tables, appRoles, errors);
|
|
6171
|
+
resolveGroupsAndParents(tables, tableByName, privatePolicy, groupRoles, errors);
|
|
5174
6172
|
const memberPolicies = (scope) => {
|
|
5175
6173
|
const out = [];
|
|
5176
6174
|
const visit = (node) => {
|
|
@@ -5221,9 +6219,14 @@ function extractSchemaTs(source) {
|
|
|
5221
6219
|
errors.push(...fileErrors);
|
|
5222
6220
|
validateFileCollections(fileCollections, (name) => {
|
|
5223
6221
|
const t = tableByName.get(name);
|
|
5224
|
-
return t ? { columns: knownColumns(t), clientWrites: Boolean(t.client && (t.client.create !== null || t.client.update !== null || t.client.delete)) } : null;
|
|
6222
|
+
return t ? { columns: knownColumns(t), clientWrites: Boolean(t.client && (t.client.create !== null || t.client.update !== null || t.client.delete)), parentTarget: fileParentTarget(t) } : null;
|
|
5225
6223
|
}, errors);
|
|
5226
6224
|
}
|
|
6225
|
+
if (groupRoles || appRoles) errors.push(...validateDeclaredRoles(groupRoles, appRoles));
|
|
6226
|
+
validateBindings({ payments, jobs, serviceRoles, groups: groupRoles, appRoles }, (name) => {
|
|
6227
|
+
const t = tableByName.get(name);
|
|
6228
|
+
return t ? { table: t, key: fileParentTarget(t).key, privateUser: fileParentTarget(t).privateUserPolicy, identity: declaredScopeIdentityColumns(t.scope) } : null;
|
|
6229
|
+
}, errors);
|
|
5227
6230
|
if (errors.length > 0) return { ok: false, errors };
|
|
5228
6231
|
return {
|
|
5229
6232
|
ok: true,
|
|
@@ -5232,7 +6235,12 @@ function extractSchemaTs(source) {
|
|
|
5232
6235
|
removedTables: removedTables.sort(),
|
|
5233
6236
|
exportedTables: exportedTables.sort(),
|
|
5234
6237
|
...managedSearch ? { search: managedSearch } : {},
|
|
5235
|
-
...fileCollections ? { files: fileCollections } : {}
|
|
6238
|
+
...fileCollections ? { files: fileCollections } : {},
|
|
6239
|
+
...groupRoles ? { groups: groupRoles } : {},
|
|
6240
|
+
...appRoles ? { appRoles } : {},
|
|
6241
|
+
...payments ? { payments } : {},
|
|
6242
|
+
...jobs ? { jobs } : {},
|
|
6243
|
+
...serviceRoles ? { serviceRoles } : {}
|
|
5236
6244
|
}
|
|
5237
6245
|
};
|
|
5238
6246
|
} catch (err) {
|
|
@@ -5264,6 +6272,9 @@ function canonicalTableShape(t) {
|
|
|
5264
6272
|
indexes: t.indexes,
|
|
5265
6273
|
uniques: t.uniques,
|
|
5266
6274
|
...t.removedColumns.length > 0 ? { removedColumns: t.removedColumns } : {},
|
|
6275
|
+
// conditional: only a group() table with direct group references gains
|
|
6276
|
+
// it, so every other table keeps its generation id.
|
|
6277
|
+
...t.groupRefs && t.groupRefs.length > 0 ? { groupRefs: t.groupRefs } : {},
|
|
5267
6278
|
// conditional: a relation-free table keeps its slice-1 generation id. Ordered
|
|
5268
6279
|
// by name (readRelations sorts) so the shape is deterministic. `kind` is
|
|
5269
6280
|
// likewise conditional — absent means hasMany, so a declaration that uses
|
|
@@ -5313,10 +6324,18 @@ function declaredFilesFromSource(files) {
|
|
|
5313
6324
|
const parsed = extractSchemaTs(source);
|
|
5314
6325
|
return parsed.ok && parsed.declaration.files?.length ? canonicalFileCollectionsJson(parsed.declaration.files) : void 0;
|
|
5315
6326
|
}
|
|
6327
|
+
function declaredRolesFromSource(files) {
|
|
6328
|
+
const source = files["db/schema.ts"];
|
|
6329
|
+
if (source === void 0) return void 0;
|
|
6330
|
+
const parsed = extractSchemaTs(source);
|
|
6331
|
+
if (!parsed.ok || !parsed.declaration.groups && !parsed.declaration.appRoles) return void 0;
|
|
6332
|
+
return JSON.stringify(canonicalRoleVocabulary(parsed.declaration.groups, parsed.declaration.appRoles));
|
|
6333
|
+
}
|
|
5316
6334
|
|
|
5317
6335
|
// declared-data-vendor-entry.js
|
|
5318
6336
|
var import_typed_data = __toESM(require_typed_data());
|
|
5319
6337
|
var import_runtime_types = __toESM(require_runtime_types());
|
|
6338
|
+
var import_groups_types = __toESM(require_groups_types());
|
|
5320
6339
|
var import_typed_files = __toESM(require_typed_files());
|
|
5321
6340
|
var import_schema_types = __toESM(require_schema_types());
|
|
5322
6341
|
var import_runtime_types2 = __toESM(require_runtime_types());
|
|
@@ -5325,6 +6344,9 @@ function generateFromFiles(files) {
|
|
|
5325
6344
|
const authority = clientAuthorityFromSource(files);
|
|
5326
6345
|
return authority ? (0, import_typed_data.generateDataClient)(authority) : void 0;
|
|
5327
6346
|
}
|
|
6347
|
+
function runtimeDeclarationFromFiles(files) {
|
|
6348
|
+
return (0, import_runtime_types.runtimeContextDeclaration)((0, import_groups_types.declaredRoles)(declaredRolesFromSource(files)));
|
|
6349
|
+
}
|
|
5328
6350
|
function declaredTablesFromFiles(files) {
|
|
5329
6351
|
return (0, import_runtime_types.declaredTablesDeclaration)(schemaAuthorityFromSource(files));
|
|
5330
6352
|
}
|
|
@@ -5340,5 +6362,6 @@ function filesDeclarationFromFiles(files) {
|
|
|
5340
6362
|
SCHEMA_DECLARATION,
|
|
5341
6363
|
declaredTablesFromFiles,
|
|
5342
6364
|
filesDeclarationFromFiles,
|
|
5343
|
-
generateFromFiles
|
|
6365
|
+
generateFromFiles,
|
|
6366
|
+
runtimeDeclarationFromFiles
|
|
5344
6367
|
});
|