rastack 0.0.51 → 0.0.52

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/auth/context.ts +73 -0
  3. package/auth/core.ts +293 -0
  4. package/auth/index.ts +23 -0
  5. package/components/auto-form/AutoForm.tsx +32 -12
  6. package/components/auto-form/use-auto-form.ts +75 -6
  7. package/components/data-table/DataTable.tsx +72 -45
  8. package/components/data-table/use-data-table.ts +57 -3
  9. package/components/index.ts +2 -0
  10. package/db.ts +9 -0
  11. package/dist/admin.js +13 -13
  12. package/dist/compile/analyze.js +7 -2
  13. package/dist/compile/entities.js +163 -16
  14. package/dist/compile/model.d.ts +7 -3
  15. package/dist/compile/openapi.js +13 -3
  16. package/dist/compile/program.d.ts +2 -1
  17. package/dist/compile/program.js +7 -2
  18. package/dist/define/auth.d.ts +27 -0
  19. package/dist/define/auth.js +42 -0
  20. package/dist/define/db.d.ts +24 -1
  21. package/dist/define/db.js +1 -1
  22. package/dist/define/index.d.ts +19 -2
  23. package/dist/define/manifest.js +10 -1
  24. package/dist/define/markers.d.ts +80 -0
  25. package/dist/define/markers.js +37 -0
  26. package/dist/define/types.d.ts +10 -0
  27. package/dist/define/types.js +26 -0
  28. package/dist/wasm/rastack_wasm.js +1 -1
  29. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  30. package/package.json +1 -1
  31. package/provider/index.ts +1 -5
  32. package/provider/provider.tsx +42 -11
  33. package/provider/types.ts +14 -2
  34. package/provider/warehouse.ts +4 -3
  35. package/provider/wasm.ts +3 -1
  36. package/runtime.ts +1 -0
  37. package/src/compile/analyze.ts +15 -3
  38. package/src/compile/entities.ts +193 -19
  39. package/src/compile/model.ts +8 -9
  40. package/src/compile/openapi.ts +11 -3
  41. package/src/compile/program.ts +10 -3
  42. package/src/define/auth.ts +28 -0
  43. package/src/define/db.ts +26 -4
  44. package/src/define/index.ts +20 -2
  45. package/src/define/manifest.ts +10 -1
  46. package/src/define/markers.ts +83 -0
  47. package/src/define/types.ts +11 -0
  48. package/test/auth.spec.ts +207 -0
  49. package/test/compile.spec.ts +18 -7
  50. package/test/components.spec.ts +116 -0
  51. package/test/entities.spec.ts +123 -6
  52. package/types.ts +7 -2
  53. package/wasm/rastack_wasm.js +1 -1
  54. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -85,7 +85,9 @@ function analyzeDsl(program, fileNames) {
85
85
  });
86
86
  }
87
87
  // Deterministic order: app, then model.
88
- resources.sort((a, b) => a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app));
88
+ resources.sort((a, b) => a.app === b.app
89
+ ? a.model.localeCompare(b.model)
90
+ : a.app.localeCompare(b.app));
89
91
  types.sort((a, b) => a.key.localeCompare(b.key));
90
92
  return { resources, types };
91
93
  }
@@ -116,7 +118,10 @@ function analyzeResourceCall(call, checker) {
116
118
  const app = stringLiteralArg(call.arguments[0]);
117
119
  const model = stringLiteralArg(call.arguments[1]);
118
120
  const fieldsArg = call.arguments[2];
119
- if (!app || !model || !fieldsArg || !ts.isObjectLiteralExpression(fieldsArg)) {
121
+ if (!app ||
122
+ !model ||
123
+ !fieldsArg ||
124
+ !ts.isObjectLiteralExpression(fieldsArg)) {
120
125
  return undefined;
121
126
  }
122
127
  const fields = [];
@@ -107,8 +107,9 @@ function analyzeEntities(program, fileNames) {
107
107
  for (const decl of candidates) {
108
108
  if (jsdocTags(decl).has("rastackresource"))
109
109
  addRoot(decl);
110
- else if (ts.isClassDeclaration(decl) && isDataClass(decl))
110
+ else if (ts.isClassDeclaration(decl) && isDataClass(decl, checker)) {
111
111
  addRoot(decl);
112
+ }
112
113
  }
113
114
  // 2. Transitive closure over FK references: an entity's entity-typed
114
115
  // property pulls the referenced declaration in as an entity too.
@@ -131,7 +132,9 @@ function analyzeEntities(program, fileNames) {
131
132
  seen.add(decl);
132
133
  included.push(decl);
133
134
  for (const prop of analyzed(decl)) {
134
- if (prop.target && candidates.has(prop.target) && !seen.has(prop.target)) {
135
+ if (prop.target &&
136
+ candidates.has(prop.target) &&
137
+ !seen.has(prop.target)) {
135
138
  queue.push(prop.target);
136
139
  }
137
140
  }
@@ -152,9 +155,14 @@ function analyzeEntities(program, fileNames) {
152
155
  });
153
156
  const fields = [];
154
157
  const relations = [];
158
+ let ownerField;
159
+ let groupField;
155
160
  for (const prop of analyzed(decl)) {
156
161
  if (prop.skip) {
157
- diagnostics.push({ severity: "warning", message: `${key}.${prop.name}: ${prop.skip}` });
162
+ diagnostics.push({
163
+ severity: "warning",
164
+ message: `${key}.${prop.name}: ${prop.skip}`,
165
+ });
158
166
  continue;
159
167
  }
160
168
  if (prop.inverse)
@@ -179,15 +187,57 @@ function analyzeEntities(program, fileNames) {
179
187
  }
180
188
  else if (prop.field) {
181
189
  fields.push(prop.field);
190
+ if (prop.owner) {
191
+ if (ownerField) {
192
+ diagnostics.push({
193
+ severity: "warning",
194
+ message: `${key}: multiple Owner fields — keeping "${ownerField}", ignoring "${prop.name}".`,
195
+ });
196
+ }
197
+ else {
198
+ ownerField = prop.name;
199
+ }
200
+ }
201
+ if (prop.group) {
202
+ if (groupField) {
203
+ diagnostics.push({
204
+ severity: "warning",
205
+ message: `${key}: multiple Group fields — keeping "${groupField}", ignoring "${prop.name}".`,
206
+ });
207
+ }
208
+ else {
209
+ groupField = prop.name;
210
+ }
211
+ }
182
212
  }
183
213
  }
214
+ const policy = entityPolicy(decl, checker, naming);
215
+ let scope = policy.scope;
216
+ if (!scope && groupField)
217
+ scope = "group"; // group datasets partition physically by default
218
+ if (scope === "group" && !groupField) {
219
+ diagnostics.push({
220
+ severity: "warning",
221
+ message: `${key}: GroupScoped without a Group field — add a \`string & Group\` property so rows can be placed in a partition.`,
222
+ });
223
+ }
224
+ const access = {};
225
+ if (ownerField)
226
+ access.ownerField = ownerField;
227
+ if (groupField)
228
+ access.groupField = groupField;
229
+ if (scope)
230
+ access.scope = scope;
231
+ if (policy.adminGroups?.length)
232
+ access.adminGroups = policy.adminGroups;
184
233
  const resource = {
185
234
  app: naming.app,
186
235
  model: naming.model,
187
236
  fields,
188
237
  relations,
189
238
  search: naming.search,
190
- permission: naming.permission,
239
+ permission: policy.permission,
240
+ access: Object.keys(access).length ? access : undefined,
191
241
  };
192
242
  // A class may declare its state machine inline: `static transitions =
193
243
  // {...}`. Statics are never columns, so the block rides alongside the
@@ -195,7 +245,9 @@ function analyzeEntities(program, fileNames) {
195
245
  (0, analyze_1.applyTransitions)(resource, staticTransitions(decl));
196
246
  resources.push(resource);
197
247
  }
198
- resources.sort((a, b) => a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app));
248
+ resources.sort((a, b) => a.app === b.app
249
+ ? a.model.localeCompare(b.model)
250
+ : a.app.localeCompare(b.app));
199
251
  return { resources, diagnostics, types };
200
252
  }
201
253
  /**
@@ -263,14 +315,23 @@ function entityBehindType(type, scanned) {
263
315
  /**
264
316
  * A *plain data class* — the class-authored equivalent of a `resource()` call,
265
317
  * so it roots itself. It must carry no heritage clause (a subclass of
266
- * `React.Component` or a service base is app code, not a table), not be
267
- * abstract, contain nothing but property declarations, and have at least one
268
- * instance property. Anything richer can still opt in via a hook or the
318
+ * `React.Component` or a service base is app code, not a table) — with one
319
+ * exception: `implements` clauses made **only of auth markers**
320
+ * (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, …) are policy, not
321
+ * behaviour, and keep the class a plain data class. It must not be abstract,
322
+ * contain nothing but property declarations, and have at least one instance
323
+ * property. Anything richer can still opt in via a hook or the
269
324
  * `@rastackResource` tag.
270
325
  */
271
- function isDataClass(decl) {
272
- if (decl.heritageClauses?.length)
273
- return false;
326
+ function isDataClass(decl, checker) {
327
+ for (const clause of decl.heritageClauses ?? []) {
328
+ if (clause.token !== ts.SyntaxKind.ImplementsKeyword)
329
+ return false;
330
+ for (const typeNode of clause.types) {
331
+ if (!isAccessMarker(checker.getTypeFromTypeNode(typeNode)))
332
+ return false;
333
+ }
334
+ }
274
335
  if (ts.getCombinedModifierFlags(decl) &
275
336
  (ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)) {
276
337
  return false;
@@ -337,7 +398,8 @@ function analyzeProperty(name, member, checker, scanned) {
337
398
  let typeNode = unwrapParens(member.type);
338
399
  if (ts.isUnionTypeNode(typeNode)) {
339
400
  const arms = typeNode.types.filter((t) => {
340
- const isNullish = (ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword) ||
401
+ const isNullish = (ts.isLiteralTypeNode(t) &&
402
+ t.literal.kind === ts.SyntaxKind.NullKeyword) ||
341
403
  t.kind === ts.SyntaxKind.UndefinedKeyword;
342
404
  if (isNullish)
343
405
  nullable = true;
@@ -449,7 +511,12 @@ function scalarProp(name, base, config, nullable) {
449
511
  field.null = true;
450
512
  if (config.default !== undefined)
451
513
  field.default = config.default;
452
- return { name, nullable, field };
514
+ const info = { name, nullable, field };
515
+ if (config.owner)
516
+ info.owner = true;
517
+ if (config.group)
518
+ info.group = true;
519
+ return info;
453
520
  }
454
521
  /**
455
522
  * Config carried in the type system: intersecting a property type with
@@ -479,6 +546,10 @@ function configFromType(type, checker, location) {
479
546
  config.unique = true;
480
547
  if (prop("primaryKey"))
481
548
  config.primaryKey = true;
549
+ if (prop("owner"))
550
+ config.owner = true;
551
+ if (prop("group"))
552
+ config.group = true;
482
553
  const maxLength = prop("maxLength");
483
554
  if (maxLength?.isNumberLiteral())
484
555
  config.maxLength = maxLength.value;
@@ -501,6 +572,10 @@ function configFromTags(tags) {
501
572
  config.unique = true;
502
573
  if (tags.has("primarykey"))
503
574
  config.primaryKey = true;
575
+ if (tags.has("owner"))
576
+ config.owner = true;
577
+ if (tags.has("group"))
578
+ config.group = true;
504
579
  const maxLength = Number(tags.get("maxlength"));
505
580
  if (Number.isFinite(maxLength) && maxLength > 0)
506
581
  config.maxLength = maxLength;
@@ -543,7 +618,8 @@ function arrayElement(node) {
543
618
  return node.elementType;
544
619
  if (ts.isTypeReferenceNode(node) &&
545
620
  ts.isIdentifier(node.typeName) &&
546
- (node.typeName.text === "Array" || node.typeName.text === "ReadonlyArray") &&
621
+ (node.typeName.text === "Array" ||
622
+ node.typeName.text === "ReadonlyArray") &&
547
623
  node.typeArguments?.length === 1) {
548
624
  return node.typeArguments[0];
549
625
  }
@@ -556,7 +632,8 @@ function entityNaming(decl) {
556
632
  // Generated entity interfaces are `I`-prefixed (`IFlight`); strip the prefix
557
633
  // so hook usage of generated types resolves to the same resource.
558
634
  const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
559
- const model = tags.get("rastackmodel") || stripped.charAt(0).toLowerCase() + stripped.slice(1);
635
+ const model = tags.get("rastackmodel") ||
636
+ stripped.charAt(0).toLowerCase() + stripped.slice(1);
560
637
  const app = tags.get("rastackapp") ||
561
638
  path
562
639
  .basename(decl.getSourceFile().fileName)
@@ -565,7 +642,10 @@ function entityNaming(decl) {
565
642
  const naming = { app, model };
566
643
  const search = tags.get("rastacksearch");
567
644
  if (search)
568
- naming.search = search.split(",").map((s) => s.trim()).filter(Boolean);
645
+ naming.search = search
646
+ .split(",")
647
+ .map((s) => s.trim())
648
+ .filter(Boolean);
569
649
  const permission = tags.get("rastackpermission");
570
650
  if (permission === "authenticatedOrReadOnly" ||
571
651
  permission === "authenticated" ||
@@ -574,6 +654,73 @@ function entityNaming(decl) {
574
654
  }
575
655
  return naming;
576
656
  }
657
+ // -- access policy (auth as types) ---------------------------------------------
658
+ /** The phantom keys the `rastack/auth` entity markers carry. */
659
+ const MARKER_PROPS = [
660
+ "__rastackPermission",
661
+ "__rastackAdminGroups",
662
+ "__rastackScope",
663
+ ];
664
+ /** Whether a heritage type is an auth marker (policy, not behaviour). */
665
+ function isAccessMarker(type) {
666
+ return MARKER_PROPS.some((p) => type.getProperty(p));
667
+ }
668
+ /**
669
+ * Read the entity's access policy: the auth markers on its heritage clauses
670
+ * (`implements IsAuthenticated, AdminGroups<"staff">` on a class, `extends`
671
+ * on an interface) merged over the JSDoc tag equivalents
672
+ * (`@rastackPermission`, `@rastackAdminGroups`, `@rastackScope`) — the type
673
+ * wins when both are present, like the `DbConfig` brands do for columns.
674
+ */
675
+ function entityPolicy(decl, checker, naming) {
676
+ const policy = {};
677
+ const tags = jsdocTags(decl);
678
+ const adminGroupsTag = tags.get("rastackadmingroups");
679
+ if (adminGroupsTag) {
680
+ const groups = adminGroupsTag
681
+ .split(",")
682
+ .map((g) => g.trim())
683
+ .filter(Boolean);
684
+ if (groups.length)
685
+ policy.adminGroups = groups;
686
+ }
687
+ const scopeTag = tags.get("rastackscope");
688
+ if (scopeTag === "owner" || scopeTag === "group" || scopeTag === "shared") {
689
+ policy.scope = scopeTag;
690
+ }
691
+ policy.permission = naming.permission; // @rastackPermission, parsed with naming
692
+ for (const clause of decl.heritageClauses ?? []) {
693
+ for (const typeNode of clause.types) {
694
+ const type = checker.getTypeFromTypeNode(typeNode);
695
+ const literals = (prop) => {
696
+ const symbol = type.getProperty(prop);
697
+ if (!symbol)
698
+ return [];
699
+ const carrier = checker
700
+ .getTypeOfSymbolAtLocation(symbol, decl)
701
+ .getNonNullableType();
702
+ const arms = carrier.isUnion() ? carrier.types : [carrier];
703
+ return arms
704
+ .filter((t) => t.isStringLiteral())
705
+ .map((t) => t.value);
706
+ };
707
+ const [permission] = literals("__rastackPermission");
708
+ if (permission === "authenticated" ||
709
+ permission === "authenticatedOrReadOnly" ||
710
+ permission === "public") {
711
+ policy.permission = permission;
712
+ }
713
+ const adminGroups = literals("__rastackAdminGroups");
714
+ if (adminGroups.length)
715
+ policy.adminGroups = adminGroups.sort();
716
+ const [scope] = literals("__rastackScope");
717
+ if (scope === "owner" || scope === "group" || scope === "shared") {
718
+ policy.scope = scope;
719
+ }
720
+ }
721
+ }
722
+ return policy;
723
+ }
577
724
  /** All JSDoc tags on a node, keyed by lower-cased tag name → comment text. */
578
725
  function jsdocTags(node) {
579
726
  const tags = new Map();
@@ -46,12 +46,16 @@ export interface AdminModel {
46
46
  /**
47
47
  * Row-level access control, enforced by `rastack-api-core` on every surface.
48
48
  * `ownerField` names the field stamped with the authenticated subject;
49
- * `scope: "owner"` places the table under a per-identity warehouse prefix
50
- * (`tenants/{sub}/…`) so object-storage IAM can fence data per user.
49
+ * `groupField` names the field holding the owning group (rows visible to its
50
+ * members). `scope: "owner"` places the table under a per-identity warehouse
51
+ * prefix (`tenants/{sub}/…`) so object-storage IAM can fence data per user;
52
+ * `scope: "group"` partitions per group (`groups/{name}/…`) so each group's
53
+ * records are physically stored apart.
51
54
  */
52
55
  export interface AccessModel {
53
56
  ownerField?: string;
54
- scope?: "owner" | "shared";
57
+ groupField?: string;
58
+ scope?: "owner" | "group" | "shared";
55
59
  adminGroups?: string[];
56
60
  }
57
61
  /**
@@ -225,19 +225,29 @@ function syncStamp(operation, resource, verb) {
225
225
  return operation;
226
226
  }
227
227
  /**
228
- * Stamp `x-rastack-access` on an operation of an access-controlled resource so
229
- * downstream tooling (hooks, the local cache) knows the owner field and whether
230
- * the table is tenant-partitioned on object storage.
228
+ * Stamp `x-rastack-permission` / `x-rastack-access` on an operation so
229
+ * downstream tooling knows the resource's full access policy: the permission
230
+ * gate, the owner field (row-level security), tenant scope (the local cache
231
+ * partitions per identity), and the RLS-bypassing admin groups — everything
232
+ * `rastack/auth`'s `policyFromOpenApi` needs to mirror the server's rules in
233
+ * the UI.
231
234
  */
232
235
  function accessStamp(operation, resource) {
236
+ if (resource.permission) {
237
+ operation["x-rastack-permission"] = resource.permission;
238
+ }
233
239
  const access = resource.access;
234
240
  if (!access)
235
241
  return;
236
242
  const stamp = {};
237
243
  if (access.ownerField)
238
244
  stamp.ownerField = access.ownerField;
245
+ if (access.groupField)
246
+ stamp.groupField = access.groupField;
239
247
  if (access.scope)
240
248
  stamp.scope = access.scope;
249
+ if (access.adminGroups?.length)
250
+ stamp.adminGroups = access.adminGroups;
241
251
  if (Object.keys(stamp).length)
242
252
  operation["x-rastack-access"] = stamp;
243
253
  }
@@ -2,7 +2,8 @@ import * as ts from "typescript";
2
2
  /**
3
3
  * Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
4
4
  * definition files. The authoring subpaths (`rastack/define` for the DSL,
5
- * `rastack/types` for the `DbConfig` column brands) are mapped to the
5
+ * `rastack/db` for the `DbConfig` column brands, `rastack/auth` for the auth
6
+ * markers, `rastack/types` as the alias for both) are mapped to the
6
7
  * in-tree/installed sources via `paths` so both the shipped package name and
7
8
  * the local demo/tests resolve without extra linking.
8
9
  */
@@ -40,7 +40,8 @@ const ts = __importStar(require("typescript"));
40
40
  /**
41
41
  * Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
42
42
  * definition files. The authoring subpaths (`rastack/define` for the DSL,
43
- * `rastack/types` for the `DbConfig` column brands) are mapped to the
43
+ * `rastack/db` for the `DbConfig` column brands, `rastack/auth` for the auth
44
+ * markers, `rastack/types` as the alias for both) are mapped to the
44
45
  * in-tree/installed sources via `paths` so both the shipped package name and
45
46
  * the local demo/tests resolve without extra linking.
46
47
  */
@@ -61,7 +62,11 @@ function createProgram(fileNames) {
61
62
  paths: {
62
63
  "rastack/define": [defineDir],
63
64
  "rastack/define/*": [defineDir + "/*"],
64
- "rastack/types": [path.join(defineDir, "db")],
65
+ // `rastack/db` = column brands, `rastack/auth` = auth markers + the
66
+ // Owner/Group brands; `rastack/types` is the compatibility alias for both.
67
+ "rastack/db": [path.join(defineDir, "db")],
68
+ "rastack/auth": [path.join(defineDir, "auth")],
69
+ "rastack/types": [path.join(defineDir, "types")],
65
70
  },
66
71
  };
67
72
  return ts.createProgram(fileNames, options);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `rastack/auth` *authoring* surface — auth as TypeScript types.
3
+ *
4
+ * Everything needed to declare an entity's access policy in the type system:
5
+ * the entity-level markers (`IsAuthenticated`, `AdminGroups<…>`,
6
+ * `OwnerScoped`, …) and the access-control field brands (`Owner`, `Group`).
7
+ * The compiler maps the `rastack/auth` import specifier here (see
8
+ * `compile/program.ts`); the published package additionally exposes the
9
+ * client runtime (`RastackAuth`, `can`, the React context) under the same
10
+ * subpath from `tools/auth/`.
11
+ *
12
+ * ```ts
13
+ * import { AdminGroups, Group, IsAuthenticated, Owner, OwnerScoped } from "rastack/auth";
14
+ *
15
+ * export class Trip implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped {
16
+ * owner!: string & Owner;
17
+ * destination!: string;
18
+ * }
19
+ *
20
+ * export class Report implements IsAuthenticated, AdminGroups<"staff"> {
21
+ * team!: string & Group; // group-owned — physically partitioned per group
22
+ * title!: string;
23
+ * }
24
+ * ```
25
+ */
26
+ export * from "./markers";
27
+ export type { Owner, Group } from "./db";
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /**
3
+ * The `rastack/auth` *authoring* surface — auth as TypeScript types.
4
+ *
5
+ * Everything needed to declare an entity's access policy in the type system:
6
+ * the entity-level markers (`IsAuthenticated`, `AdminGroups<…>`,
7
+ * `OwnerScoped`, …) and the access-control field brands (`Owner`, `Group`).
8
+ * The compiler maps the `rastack/auth` import specifier here (see
9
+ * `compile/program.ts`); the published package additionally exposes the
10
+ * client runtime (`RastackAuth`, `can`, the React context) under the same
11
+ * subpath from `tools/auth/`.
12
+ *
13
+ * ```ts
14
+ * import { AdminGroups, Group, IsAuthenticated, Owner, OwnerScoped } from "rastack/auth";
15
+ *
16
+ * export class Trip implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped {
17
+ * owner!: string & Owner;
18
+ * destination!: string;
19
+ * }
20
+ *
21
+ * export class Report implements IsAuthenticated, AdminGroups<"staff"> {
22
+ * team!: string & Group; // group-owned — physically partitioned per group
23
+ * title!: string;
24
+ * }
25
+ * ```
26
+ */
27
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ var desc = Object.getOwnPropertyDescriptor(m, k);
30
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
31
+ desc = { enumerable: true, get: function() { return m[k]; } };
32
+ }
33
+ Object.defineProperty(o, k2, desc);
34
+ }) : (function(o, m, k, k2) {
35
+ if (k2 === undefined) k2 = k;
36
+ o[k2] = m[k];
37
+ }));
38
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
39
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
40
+ };
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ __exportStar(require("./markers"), exports);
@@ -7,7 +7,7 @@
7
7
  * off the type — the same way it reads foreign keys.
8
8
  *
9
9
  * ```ts
10
- * import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/types";
10
+ * import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/db";
11
11
  *
12
12
  * export interface Passenger {
13
13
  * id: UUID & PrimaryKey;
@@ -59,6 +59,29 @@ export interface Default<V extends string | number | boolean> extends DbConfig<{
59
59
  default: V;
60
60
  }> {
61
61
  }
62
+ /**
63
+ * Marks the column that records the owning identity (the verified token
64
+ * `sub`): `owner: string & Owner`. Compiles to `access.ownerField` — the row
65
+ * is stamped from the token on create, immutable after, and rows are only
66
+ * visible to their owner (plus `AdminGroups` members). Add the `OwnerScoped`
67
+ * entity marker to *also* partition the table physically per identity.
68
+ */
69
+ export interface Owner extends DbConfig<{
70
+ owner: true;
71
+ }> {
72
+ }
73
+ /**
74
+ * Marks the column that records the owning group (a verified `cognito:groups`
75
+ * value): `team: string & Group`. Compiles to `access.groupField` — rows are
76
+ * only visible to members of their group, and the dataset is **physically
77
+ * partitioned per group** by default (`groups/{name}/…` on object storage),
78
+ * so each group's records live in their own files. Add the `SharedStorage`
79
+ * entity marker to keep group rows in one shared table instead.
80
+ */
81
+ export interface Group extends DbConfig<{
82
+ group: true;
83
+ }> {
84
+ }
62
85
  /** An integer column — a bare `number` property maps to `float`. */
63
86
  export type Int = number & DbConfig<{
64
87
  int: true;
package/dist/define/db.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * off the type — the same way it reads foreign keys.
9
9
  *
10
10
  * ```ts
11
- * import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/types";
11
+ * import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/db";
12
12
  *
13
13
  * export interface Passenger {
14
14
  * id: UUID & PrimaryKey;
@@ -124,17 +124,34 @@ export interface SyncOptions {
124
124
  * - `ownerField` — the field that records the owning identity (the verified
125
125
  * token `sub`). Rows are only listed/read/written by their owner; the field
126
126
  * is stamped from the token on create and immutable on update.
127
+ * - `groupField` — the field that records the owning *group* (a verified
128
+ * `cognito:groups` value). Rows are only visible to members of their group,
129
+ * and the dataset defaults to per-group physical partitioning.
127
130
  * - `scope: "owner"` — additionally *physically* partitions the resource's
128
131
  * Iceberg table per identity (`tenants/{sub}/{app}.db/{model}/…`), so
129
132
  * object-storage credentials (e.g. Cognito Identity Pool temp creds) can be
130
133
  * IAM-restricted to `tenants/${"${cognito-identity.amazonaws.com:sub}"}/*`.
134
+ * `scope: "group"` partitions per group instead (`groups/{name}/…`);
135
+ * `scope: "shared"` keeps one table.
131
136
  * - `adminGroups` — token groups (`cognito:groups`) that bypass row filters.
137
+ *
138
+ * Class/interface entities express the same policy in the type system — the
139
+ * `Owner`/`Group` field brands and `IsAuthenticated`/`AdminGroups<…>`/
140
+ * `OwnerScoped` markers from `rastack/auth`.
132
141
  */
133
142
  export interface AccessOptions {
134
143
  ownerField?: string;
135
- scope?: "owner" | "shared";
144
+ groupField?: string;
145
+ scope?: "owner" | "group" | "shared";
136
146
  adminGroups?: string[];
137
147
  }
148
+ /**
149
+ * The resource-level permission gate — which paths an identity may hit at all.
150
+ * Enforced by `rastack-api-core` before any row is read; mirrored client-side
151
+ * by `rastack/auth`'s `can()` so components hide what the server would reject.
152
+ * Unset means `authenticated` — secure by default.
153
+ */
154
+ export type Permission = "authenticatedOrReadOnly" | "authenticated" | "public";
138
155
  /**
139
156
  * One edge of a resource's state machine. `from` is the state (or states) the
140
157
  * transition may fire from, `to` the state it enters, and `set` the effect —
@@ -193,7 +210,7 @@ export interface ResourceOptions {
193
210
  /** Local-first replication config. */
194
211
  sync?: SyncOptions;
195
212
  /** Access policy (secure by default). */
196
- permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
213
+ permission?: Permission;
197
214
  /** Row-level security + physical tenant partitioning. */
198
215
  access?: AccessOptions;
199
216
  /** Declarative state machine over one field, enforced on every write surface. */
@@ -230,9 +230,18 @@ function normaliseAccess(access) {
230
230
  const model = {};
231
231
  if (typeof access.ownerField === "string")
232
232
  model.ownerField = access.ownerField;
233
- if (access.scope === "owner" || access.scope === "shared") {
233
+ if (typeof access.groupField === "string")
234
+ model.groupField = access.groupField;
235
+ if (access.scope === "owner" ||
236
+ access.scope === "group" ||
237
+ access.scope === "shared") {
234
238
  model.scope = access.scope;
235
239
  }
240
+ else if (model.groupField) {
241
+ // Group-owned datasets are physically partitioned per group by default —
242
+ // that is their point. `scope: "shared"` opts out explicitly.
243
+ model.scope = "group";
244
+ }
236
245
  if (Array.isArray(access.adminGroups)) {
237
246
  model.adminGroups = access.adminGroups.filter((g) => typeof g === "string");
238
247
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Entity-level authorization markers — auth as *TypeScript types*.
3
+ *
4
+ * Class and interface entities have no options bag, so their access policy is
5
+ * expressed the same way their columns are: in the type system. Attach a
6
+ * marker with `implements` (classes) or `extends` (interfaces) and the
7
+ * compiler reads the policy straight off the heritage clause:
8
+ *
9
+ * ```ts
10
+ * import { AdminGroups, IsAuthenticated, Owner, Group } from "rastack/auth";
11
+ *
12
+ * // Per-user rows: RLS on `owner`, staff sees everything.
13
+ * export class Itinerary implements IsAuthenticated, AdminGroups<"staff"> {
14
+ * owner!: string & Owner;
15
+ * notes!: string;
16
+ * }
17
+ *
18
+ * // Group-owned dataset: rows belong to a team and are *physically stored*
19
+ * // under that team's own storage prefix (groups/{team}/…).
20
+ * export class Report implements IsAuthenticated, AdminGroups<"staff"> {
21
+ * team!: string & Group;
22
+ * title!: string;
23
+ * }
24
+ * ```
25
+ *
26
+ * The markers are phantom types: every property is optional and never exists
27
+ * at runtime, so `implements` needs no members and instances stay plain data.
28
+ * Each marker family uses its own phantom key, so markers combine freely —
29
+ * while two *permission* markers on one entity conflict in the type checker,
30
+ * which is exactly right.
31
+ *
32
+ * JSDoc equivalents exist for every marker (`@rastackPermission`,
33
+ * `@rastackAdminGroups staff, ops`, `@rastackScope owner|group|shared`, and
34
+ * field-level `@owner` / `@group`), mirroring the column-constraint tags.
35
+ */
36
+ /** The permission gate carried in the type system. */
37
+ export interface PermissionOf<P extends string> {
38
+ readonly __rastackPermission?: P;
39
+ }
40
+ /** Every path requires a verified identity (this is also the default). */
41
+ export interface IsAuthenticated extends PermissionOf<"authenticated"> {
42
+ }
43
+ /** Anonymous callers may read (list/retrieve); writes require identity. */
44
+ export interface IsAuthenticatedOrReadOnly extends PermissionOf<"authenticatedOrReadOnly"> {
45
+ }
46
+ /** No gate — every caller may read and write. Opt-in, never the default. */
47
+ export interface IsPublic extends PermissionOf<"public"> {
48
+ }
49
+ /**
50
+ * Token groups (`cognito:groups`) that bypass row-level security on this
51
+ * entity — the "is admin" role, driven from scopes: `implements
52
+ * AdminGroups<"staff" | "ops">`. Members see and act on every row.
53
+ */
54
+ export interface AdminGroups<G extends string> {
55
+ readonly __rastackAdminGroups?: G;
56
+ }
57
+ /** Storage scope carried in the type system. */
58
+ export interface ScopeOf<S extends string> {
59
+ readonly __rastackScope?: S;
60
+ }
61
+ /**
62
+ * Physically partition the entity's table per identity
63
+ * (`tenants/{sub}/…`) — the prefix object-storage IAM can fence per user.
64
+ * Combine with an `Owner` field brand.
65
+ */
66
+ export interface OwnerScoped extends ScopeOf<"owner"> {
67
+ }
68
+ /**
69
+ * Physically partition the entity's table per group (`groups/{name}/…`).
70
+ * This is the default whenever a `Group` field brand is present — declare it
71
+ * only for emphasis or with a JSDoc-configured group field.
72
+ */
73
+ export interface GroupScoped extends ScopeOf<"group"> {
74
+ }
75
+ /**
76
+ * Keep rows in one shared table (row-level filters still apply). Use to opt a
77
+ * `Group`-branded entity out of the default per-group physical partitioning.
78
+ */
79
+ export interface SharedStorage extends ScopeOf<"shared"> {
80
+ }