rastack 0.0.50 → 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 (72) hide show
  1. package/CHANGELOG.md +4 -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.d.ts +1 -7
  13. package/dist/compile/analyze.js +19 -70
  14. package/dist/compile/entities.js +163 -16
  15. package/dist/compile/model.d.ts +7 -3
  16. package/dist/compile/openapi.js +13 -3
  17. package/dist/compile/program.d.ts +2 -1
  18. package/dist/compile/program.js +7 -2
  19. package/dist/define/auth.d.ts +27 -0
  20. package/dist/define/auth.js +42 -0
  21. package/dist/define/db.d.ts +24 -1
  22. package/dist/define/db.js +1 -1
  23. package/dist/define/index.d.ts +82 -18
  24. package/dist/define/index.js +27 -21
  25. package/dist/define/manifest.d.ts +64 -0
  26. package/dist/define/manifest.js +259 -0
  27. package/dist/define/markers.d.ts +80 -0
  28. package/dist/define/markers.js +37 -0
  29. package/dist/define/types.d.ts +10 -0
  30. package/dist/define/types.js +26 -0
  31. package/dist/plugin/core.d.ts +108 -0
  32. package/dist/plugin/core.js +198 -0
  33. package/dist/plugin/index.d.ts +112 -0
  34. package/dist/plugin/index.js +203 -0
  35. package/dist/wasm/rastack_wasm.js +1 -1
  36. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  37. package/hooks/data.ts +18 -6
  38. package/hooks/entity.ts +11 -5
  39. package/hooks/form/entity-form.ts +12 -8
  40. package/hooks/manifest.ts +50 -14
  41. package/hooks/registry.ts +25 -8
  42. package/package.json +1 -1
  43. package/plugin.ts +8 -0
  44. package/provider/index.ts +1 -5
  45. package/provider/provider.tsx +61 -16
  46. package/provider/types.ts +26 -7
  47. package/provider/warehouse.ts +4 -3
  48. package/provider/wasm.ts +3 -1
  49. package/runtime.ts +1 -0
  50. package/src/compile/analyze.ts +27 -65
  51. package/src/compile/entities.ts +193 -19
  52. package/src/compile/model.ts +8 -9
  53. package/src/compile/openapi.ts +11 -3
  54. package/src/compile/program.ts +10 -3
  55. package/src/define/auth.ts +28 -0
  56. package/src/define/db.ts +26 -4
  57. package/src/define/index.ts +118 -24
  58. package/src/define/manifest.ts +287 -0
  59. package/src/define/markers.ts +83 -0
  60. package/src/define/types.ts +11 -0
  61. package/src/plugin/core.ts +236 -0
  62. package/src/plugin/index.ts +243 -0
  63. package/test/auth.spec.ts +207 -0
  64. package/test/compile.spec.ts +18 -7
  65. package/test/components.spec.ts +116 -0
  66. package/test/entities.spec.ts +123 -6
  67. package/test/plugin.spec.ts +315 -0
  68. package/test/runtime-manifest.spec.ts +309 -0
  69. package/test/typed-hooks.spec.ts +7 -2
  70. package/types.ts +7 -2
  71. package/wasm/rastack_wasm.js +1 -1
  72. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -25,7 +25,7 @@ import { applyTransitions, literalToValue } from "./analyze";
25
25
  * 2. **Datatypes come from the properties.** Each property's TypeScript type
26
26
  * maps to a manifest scalar (`string` → string, `number` → float,
27
27
  * `boolean` → bool, `Date` → datetime). Constraints are carried in the
28
- * type system with `DbConfig` brands from `rastack/types` —
28
+ * type system with `DbConfig` brands from `rastack/db` —
29
29
  * `code: string & Unique & MaxLength<3>`, `age: Int` — or, equivalently,
30
30
  * as JSDoc tags (`@unique`, `@maxLength 3`, `@int`, `@uuid`, `@default`).
31
31
  * Optional / `| null` properties become nullable fields. On a class, a
@@ -166,7 +166,9 @@ export function analyzeEntities(
166
166
  }
167
167
  for (const decl of candidates) {
168
168
  if (jsdocTags(decl).has("rastackresource")) addRoot(decl);
169
- else if (ts.isClassDeclaration(decl) && isDataClass(decl)) addRoot(decl);
169
+ else if (ts.isClassDeclaration(decl) && isDataClass(decl, checker)) {
170
+ addRoot(decl);
171
+ }
170
172
  }
171
173
 
172
174
  // 2. Transitive closure over FK references: an entity's entity-typed
@@ -190,7 +192,11 @@ export function analyzeEntities(
190
192
  seen.add(decl);
191
193
  included.push(decl);
192
194
  for (const prop of analyzed(decl)) {
193
- if (prop.target && candidates.has(prop.target) && !seen.has(prop.target)) {
195
+ if (
196
+ prop.target &&
197
+ candidates.has(prop.target) &&
198
+ !seen.has(prop.target)
199
+ ) {
194
200
  queue.push(prop.target);
195
201
  }
196
202
  }
@@ -215,9 +221,14 @@ export function analyzeEntities(
215
221
  const fields: FieldModel[] = [];
216
222
  const relations: RelationModel[] = [];
217
223
 
224
+ let ownerField: string | undefined;
225
+ let groupField: string | undefined;
218
226
  for (const prop of analyzed(decl)) {
219
227
  if (prop.skip) {
220
- diagnostics.push({ severity: "warning", message: `${key}.${prop.name}: ${prop.skip}` });
228
+ diagnostics.push({
229
+ severity: "warning",
230
+ message: `${key}.${prop.name}: ${prop.skip}`,
231
+ });
221
232
  continue;
222
233
  }
223
234
  if (prop.inverse) continue; // has-many arrays are the reverse side of a FK
@@ -238,16 +249,53 @@ export function analyzeEntities(
238
249
  });
239
250
  } else if (prop.field) {
240
251
  fields.push(prop.field);
252
+ if (prop.owner) {
253
+ if (ownerField) {
254
+ diagnostics.push({
255
+ severity: "warning",
256
+ message: `${key}: multiple Owner fields — keeping "${ownerField}", ignoring "${prop.name}".`,
257
+ });
258
+ } else {
259
+ ownerField = prop.name;
260
+ }
261
+ }
262
+ if (prop.group) {
263
+ if (groupField) {
264
+ diagnostics.push({
265
+ severity: "warning",
266
+ message: `${key}: multiple Group fields — keeping "${groupField}", ignoring "${prop.name}".`,
267
+ });
268
+ } else {
269
+ groupField = prop.name;
270
+ }
271
+ }
241
272
  }
242
273
  }
243
274
 
275
+ const policy = entityPolicy(decl, checker, naming);
276
+ let scope = policy.scope;
277
+ if (!scope && groupField) scope = "group"; // group datasets partition physically by default
278
+ if (scope === "group" && !groupField) {
279
+ diagnostics.push({
280
+ severity: "warning",
281
+ message: `${key}: GroupScoped without a Group field — add a \`string & Group\` property so rows can be placed in a partition.`,
282
+ });
283
+ }
284
+
285
+ const access: NonNullable<ResourceModel["access"]> = {};
286
+ if (ownerField) access.ownerField = ownerField;
287
+ if (groupField) access.groupField = groupField;
288
+ if (scope) access.scope = scope;
289
+ if (policy.adminGroups?.length) access.adminGroups = policy.adminGroups;
290
+
244
291
  const resource: ResourceModel = {
245
292
  app: naming.app,
246
293
  model: naming.model,
247
294
  fields,
248
295
  relations,
249
296
  search: naming.search,
250
- permission: naming.permission,
297
+ permission: policy.permission,
298
+ access: Object.keys(access).length ? access : undefined,
251
299
  };
252
300
  // A class may declare its state machine inline: `static transitions =
253
301
  // {...}`. Statics are never columns, so the block rides alongside the
@@ -257,7 +305,9 @@ export function analyzeEntities(
257
305
  }
258
306
 
259
307
  resources.sort((a, b) =>
260
- a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
308
+ a.app === b.app
309
+ ? a.model.localeCompare(b.model)
310
+ : a.app.localeCompare(b.app),
261
311
  );
262
312
  return { resources, diagnostics, types };
263
313
  }
@@ -344,13 +394,24 @@ function entityBehindType(
344
394
  /**
345
395
  * A *plain data class* — the class-authored equivalent of a `resource()` call,
346
396
  * so it roots itself. It must carry no heritage clause (a subclass of
347
- * `React.Component` or a service base is app code, not a table), not be
348
- * abstract, contain nothing but property declarations, and have at least one
349
- * instance property. Anything richer can still opt in via a hook or the
397
+ * `React.Component` or a service base is app code, not a table) — with one
398
+ * exception: `implements` clauses made **only of auth markers**
399
+ * (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, …) are policy, not
400
+ * behaviour, and keep the class a plain data class. It must not be abstract,
401
+ * contain nothing but property declarations, and have at least one instance
402
+ * property. Anything richer can still opt in via a hook or the
350
403
  * `@rastackResource` tag.
351
404
  */
352
- function isDataClass(decl: ts.ClassDeclaration): boolean {
353
- if (decl.heritageClauses?.length) return false;
405
+ function isDataClass(
406
+ decl: ts.ClassDeclaration,
407
+ checker: ts.TypeChecker,
408
+ ): boolean {
409
+ for (const clause of decl.heritageClauses ?? []) {
410
+ if (clause.token !== ts.SyntaxKind.ImplementsKeyword) return false;
411
+ for (const typeNode of clause.types) {
412
+ if (!isAccessMarker(checker.getTypeFromTypeNode(typeNode))) return false;
413
+ }
414
+ }
354
415
  if (
355
416
  ts.getCombinedModifierFlags(decl) &
356
417
  (ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)
@@ -379,6 +440,10 @@ interface PropInfo {
379
440
  inverse?: boolean;
380
441
  /** Unsupported type — reported as a warning and skipped. */
381
442
  skip?: string;
443
+ /** This column is the resource's `access.ownerField`. */
444
+ owner?: boolean;
445
+ /** This column is the resource's `access.groupField`. */
446
+ group?: boolean;
382
447
  }
383
448
 
384
449
  /** A column-shaped member: an interface property or a class instance field. */
@@ -449,13 +514,19 @@ function analyzeProperty(
449
514
  if (ts.isUnionTypeNode(typeNode)) {
450
515
  const arms = typeNode.types.filter((t) => {
451
516
  const isNullish =
452
- (ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword) ||
517
+ (ts.isLiteralTypeNode(t) &&
518
+ t.literal.kind === ts.SyntaxKind.NullKeyword) ||
453
519
  t.kind === ts.SyntaxKind.UndefinedKeyword;
454
520
  if (isNullish) nullable = true;
455
521
  return !isNullish;
456
522
  });
457
523
  if (arms.length > 1 && arms.every(isStringish)) {
458
- return scalarProp(name, "string", configFromTags(jsdocTags(member)), nullable);
524
+ return scalarProp(
525
+ name,
526
+ "string",
527
+ configFromTags(jsdocTags(member)),
528
+ nullable,
529
+ );
459
530
  }
460
531
  if (arms.length !== 1) {
461
532
  return { name, nullable, skip: "unsupported union type — skipped" };
@@ -497,7 +568,8 @@ function analyzeProperty(
497
568
  function initializerDefault(
498
569
  member: EntityProp,
499
570
  ): string | number | boolean | undefined {
500
- if (!ts.isPropertyDeclaration(member) || !member.initializer) return undefined;
571
+ if (!ts.isPropertyDeclaration(member) || !member.initializer)
572
+ return undefined;
501
573
  const init = member.initializer;
502
574
  if (ts.isStringLiteralLike(init)) return init.text;
503
575
  if (ts.isNumericLiteral(init)) return Number(init.text);
@@ -558,7 +630,10 @@ function scalarProp(
558
630
  if (config.primaryKey) field.primaryKey = true;
559
631
  if (nullable) field.null = true;
560
632
  if (config.default !== undefined) field.default = config.default;
561
- return { name, nullable, field };
633
+ const info: PropInfo = { name, nullable, field };
634
+ if (config.owner) info.owner = true;
635
+ if (config.group) info.group = true;
636
+ return info;
562
637
  }
563
638
 
564
639
  // -- column configuration -------------------------------------------------------
@@ -572,6 +647,10 @@ interface ColumnConfig {
572
647
  unique?: boolean;
573
648
  primaryKey?: boolean;
574
649
  default?: unknown;
650
+ /** `string & Owner` / `@owner` — this column records the owning subject. */
651
+ owner?: boolean;
652
+ /** `string & Group` / `@group` — this column records the owning group. */
653
+ group?: boolean;
575
654
  }
576
655
 
577
656
  /**
@@ -601,6 +680,8 @@ function configFromType(
601
680
  if (prop("datetime")) config.datetime = true;
602
681
  if (prop("unique")) config.unique = true;
603
682
  if (prop("primaryKey")) config.primaryKey = true;
683
+ if (prop("owner")) config.owner = true;
684
+ if (prop("group")) config.group = true;
604
685
  const maxLength = prop("maxLength");
605
686
  if (maxLength?.isNumberLiteral()) config.maxLength = maxLength.value;
606
687
  const dflt = prop("default");
@@ -617,6 +698,8 @@ function configFromTags(tags: Map<string, string>): ColumnConfig {
617
698
  if (tags.has("datetime")) config.datetime = true;
618
699
  if (tags.has("unique")) config.unique = true;
619
700
  if (tags.has("primarykey")) config.primaryKey = true;
701
+ if (tags.has("owner")) config.owner = true;
702
+ if (tags.has("group")) config.group = true;
620
703
  const maxLength = Number(tags.get("maxlength"));
621
704
  if (Number.isFinite(maxLength) && maxLength > 0) config.maxLength = maxLength;
622
705
  const dflt = tags.get("default");
@@ -650,7 +733,8 @@ function unwrapParens(node: ts.TypeNode): ts.TypeNode {
650
733
  /** `string`, a string-literal type, or a template literal type. */
651
734
  function isStringish(node: ts.TypeNode): boolean {
652
735
  if (node.kind === ts.SyntaxKind.StringKeyword) return true;
653
- if (ts.isLiteralTypeNode(node) && ts.isStringLiteralLike(node.literal)) return true;
736
+ if (ts.isLiteralTypeNode(node) && ts.isStringLiteralLike(node.literal))
737
+ return true;
654
738
  return ts.isTemplateLiteralTypeNode(node);
655
739
  }
656
740
 
@@ -660,7 +744,8 @@ function arrayElement(node: ts.TypeNode): ts.TypeNode | undefined {
660
744
  if (
661
745
  ts.isTypeReferenceNode(node) &&
662
746
  ts.isIdentifier(node.typeName) &&
663
- (node.typeName.text === "Array" || node.typeName.text === "ReadonlyArray") &&
747
+ (node.typeName.text === "Array" ||
748
+ node.typeName.text === "ReadonlyArray") &&
664
749
  node.typeArguments?.length === 1
665
750
  ) {
666
751
  return node.typeArguments[0];
@@ -685,7 +770,8 @@ function entityNaming(decl: EntityDecl): EntityNaming {
685
770
  // so hook usage of generated types resolves to the same resource.
686
771
  const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
687
772
  const model =
688
- tags.get("rastackmodel") || stripped.charAt(0).toLowerCase() + stripped.slice(1);
773
+ tags.get("rastackmodel") ||
774
+ stripped.charAt(0).toLowerCase() + stripped.slice(1);
689
775
  const app =
690
776
  tags.get("rastackapp") ||
691
777
  path
@@ -695,7 +781,11 @@ function entityNaming(decl: EntityDecl): EntityNaming {
695
781
 
696
782
  const naming: EntityNaming = { app, model };
697
783
  const search = tags.get("rastacksearch");
698
- if (search) naming.search = search.split(",").map((s) => s.trim()).filter(Boolean);
784
+ if (search)
785
+ naming.search = search
786
+ .split(",")
787
+ .map((s) => s.trim())
788
+ .filter(Boolean);
699
789
  const permission = tags.get("rastackpermission");
700
790
  if (
701
791
  permission === "authenticatedOrReadOnly" ||
@@ -707,6 +797,90 @@ function entityNaming(decl: EntityDecl): EntityNaming {
707
797
  return naming;
708
798
  }
709
799
 
800
+ // -- access policy (auth as types) ---------------------------------------------
801
+
802
+ /** The phantom keys the `rastack/auth` entity markers carry. */
803
+ const MARKER_PROPS = [
804
+ "__rastackPermission",
805
+ "__rastackAdminGroups",
806
+ "__rastackScope",
807
+ ] as const;
808
+
809
+ /** Whether a heritage type is an auth marker (policy, not behaviour). */
810
+ function isAccessMarker(type: ts.Type): boolean {
811
+ return MARKER_PROPS.some((p) => type.getProperty(p));
812
+ }
813
+
814
+ /** An entity's access policy, however it was authored. */
815
+ interface EntityPolicy {
816
+ permission?: ResourceModel["permission"];
817
+ adminGroups?: string[];
818
+ scope?: "owner" | "group" | "shared";
819
+ }
820
+
821
+ /**
822
+ * Read the entity's access policy: the auth markers on its heritage clauses
823
+ * (`implements IsAuthenticated, AdminGroups<"staff">` on a class, `extends`
824
+ * on an interface) merged over the JSDoc tag equivalents
825
+ * (`@rastackPermission`, `@rastackAdminGroups`, `@rastackScope`) — the type
826
+ * wins when both are present, like the `DbConfig` brands do for columns.
827
+ */
828
+ function entityPolicy(
829
+ decl: EntityDecl,
830
+ checker: ts.TypeChecker,
831
+ naming: EntityNaming,
832
+ ): EntityPolicy {
833
+ const policy: EntityPolicy = {};
834
+ const tags = jsdocTags(decl);
835
+
836
+ const adminGroupsTag = tags.get("rastackadmingroups");
837
+ if (adminGroupsTag) {
838
+ const groups = adminGroupsTag
839
+ .split(",")
840
+ .map((g) => g.trim())
841
+ .filter(Boolean);
842
+ if (groups.length) policy.adminGroups = groups;
843
+ }
844
+ const scopeTag = tags.get("rastackscope");
845
+ if (scopeTag === "owner" || scopeTag === "group" || scopeTag === "shared") {
846
+ policy.scope = scopeTag;
847
+ }
848
+ policy.permission = naming.permission; // @rastackPermission, parsed with naming
849
+
850
+ for (const clause of decl.heritageClauses ?? []) {
851
+ for (const typeNode of clause.types) {
852
+ const type = checker.getTypeFromTypeNode(typeNode);
853
+ const literals = (prop: string): string[] => {
854
+ const symbol = type.getProperty(prop);
855
+ if (!symbol) return [];
856
+ const carrier = checker
857
+ .getTypeOfSymbolAtLocation(symbol, decl)
858
+ .getNonNullableType();
859
+ const arms = carrier.isUnion() ? carrier.types : [carrier];
860
+ return arms
861
+ .filter((t): t is ts.StringLiteralType => t.isStringLiteral())
862
+ .map((t) => t.value);
863
+ };
864
+
865
+ const [permission] = literals("__rastackPermission");
866
+ if (
867
+ permission === "authenticated" ||
868
+ permission === "authenticatedOrReadOnly" ||
869
+ permission === "public"
870
+ ) {
871
+ policy.permission = permission;
872
+ }
873
+ const adminGroups = literals("__rastackAdminGroups");
874
+ if (adminGroups.length) policy.adminGroups = adminGroups.sort();
875
+ const [scope] = literals("__rastackScope");
876
+ if (scope === "owner" || scope === "group" || scope === "shared") {
877
+ policy.scope = scope;
878
+ }
879
+ }
880
+ }
881
+ return policy;
882
+ }
883
+
710
884
  /** All JSDoc tags on a node, keyed by lower-cased tag name → comment text. */
711
885
  function jsdocTags(node: ts.Node): Map<string, string> {
712
886
  const tags = new Map<string, string>();
@@ -5,12 +5,7 @@
5
5
  */
6
6
 
7
7
  export type ScalarType =
8
- | "string"
9
- | "int"
10
- | "float"
11
- | "bool"
12
- | "datetime"
13
- | "uuid";
8
+ "string" | "int" | "float" | "bool" | "datetime" | "uuid";
14
9
 
15
10
  export type FieldType = ScalarType | "fk";
16
11
 
@@ -60,12 +55,16 @@ export interface AdminModel {
60
55
  /**
61
56
  * Row-level access control, enforced by `rastack-api-core` on every surface.
62
57
  * `ownerField` names the field stamped with the authenticated subject;
63
- * `scope: "owner"` places the table under a per-identity warehouse prefix
64
- * (`tenants/{sub}/…`) so object-storage IAM can fence data per user.
58
+ * `groupField` names the field holding the owning group (rows visible to its
59
+ * members). `scope: "owner"` places the table under a per-identity warehouse
60
+ * prefix (`tenants/{sub}/…`) so object-storage IAM can fence data per user;
61
+ * `scope: "group"` partitions per group (`groups/{name}/…`) so each group's
62
+ * records are physically stored apart.
65
63
  */
66
64
  export interface AccessModel {
67
65
  ownerField?: string;
68
- scope?: "owner" | "shared";
66
+ groupField?: string;
67
+ scope?: "owner" | "group" | "shared";
69
68
  adminGroups?: string[];
70
69
  }
71
70
 
@@ -257,16 +257,24 @@ function syncStamp(operation: any, resource: ResourceModel, verb: string): any {
257
257
  }
258
258
 
259
259
  /**
260
- * Stamp `x-rastack-access` on an operation of an access-controlled resource so
261
- * downstream tooling (hooks, the local cache) knows the owner field and whether
262
- * the table is tenant-partitioned on object storage.
260
+ * Stamp `x-rastack-permission` / `x-rastack-access` on an operation so
261
+ * downstream tooling knows the resource's full access policy: the permission
262
+ * gate, the owner field (row-level security), tenant scope (the local cache
263
+ * partitions per identity), and the RLS-bypassing admin groups — everything
264
+ * `rastack/auth`'s `policyFromOpenApi` needs to mirror the server's rules in
265
+ * the UI.
263
266
  */
264
267
  function accessStamp(operation: any, resource: ResourceModel): void {
268
+ if (resource.permission) {
269
+ operation["x-rastack-permission"] = resource.permission;
270
+ }
265
271
  const access = resource.access;
266
272
  if (!access) return;
267
273
  const stamp: any = {};
268
274
  if (access.ownerField) stamp.ownerField = access.ownerField;
275
+ if (access.groupField) stamp.groupField = access.groupField;
269
276
  if (access.scope) stamp.scope = access.scope;
277
+ if (access.adminGroups?.length) stamp.adminGroups = access.adminGroups;
270
278
  if (Object.keys(stamp).length) operation["x-rastack-access"] = stamp;
271
279
  }
272
280
 
@@ -4,7 +4,8 @@ import * as ts from "typescript";
4
4
  /**
5
5
  * Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
6
6
  * definition files. The authoring subpaths (`rastack/define` for the DSL,
7
- * `rastack/types` for the `DbConfig` column brands) are mapped to the
7
+ * `rastack/db` for the `DbConfig` column brands, `rastack/auth` for the auth
8
+ * markers, `rastack/types` as the alias for both) are mapped to the
8
9
  * in-tree/installed sources via `paths` so both the shipped package name and
9
10
  * the local demo/tests resolve without extra linking.
10
11
  */
@@ -26,7 +27,11 @@ export function createProgram(fileNames: string[]): ts.Program {
26
27
  paths: {
27
28
  "rastack/define": [defineDir],
28
29
  "rastack/define/*": [defineDir + "/*"],
29
- "rastack/types": [path.join(defineDir, "db")],
30
+ // `rastack/db` = column brands, `rastack/auth` = auth markers + the
31
+ // Owner/Group brands; `rastack/types` is the compatibility alias for both.
32
+ "rastack/db": [path.join(defineDir, "db")],
33
+ "rastack/auth": [path.join(defineDir, "auth")],
34
+ "rastack/types": [path.join(defineDir, "types")],
30
35
  },
31
36
  };
32
37
 
@@ -41,5 +46,7 @@ export function resourceSourceFiles(
41
46
  const wanted = new Set(fileNames.map((f) => path.resolve(f)));
42
47
  return program
43
48
  .getSourceFiles()
44
- .filter((sf) => !sf.isDeclarationFile && wanted.has(path.resolve(sf.fileName)));
49
+ .filter(
50
+ (sf) => !sf.isDeclarationFile && wanted.has(path.resolve(sf.fileName)),
51
+ );
45
52
  }
@@ -0,0 +1,28 @@
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
+
27
+ export * from "./markers";
28
+ export type { Owner, Group } from "./db";
package/src/define/db.ts CHANGED
@@ -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;
@@ -48,11 +48,33 @@ export interface Unique extends DbConfig<{ unique: true }> {}
48
48
  export interface PrimaryKey extends DbConfig<{ primaryKey: true }> {}
49
49
 
50
50
  /** Maximum length for string columns: `string & MaxLength<120>`. */
51
- export interface MaxLength<N extends number> extends DbConfig<{ maxLength: N }> {}
51
+ export interface MaxLength<N extends number> extends DbConfig<{
52
+ maxLength: N;
53
+ }> {}
52
54
 
53
55
  /** Server-side default value: `string & Default<"scheduled">`, `number & Default<0>`. */
54
- export interface Default<V extends string | number | boolean>
55
- extends DbConfig<{ default: V }> {}
56
+ export interface Default<V extends string | number | boolean> extends DbConfig<{
57
+ default: V;
58
+ }> {}
59
+
60
+ /**
61
+ * Marks the column that records the owning identity (the verified token
62
+ * `sub`): `owner: string & Owner`. Compiles to `access.ownerField` — the row
63
+ * is stamped from the token on create, immutable after, and rows are only
64
+ * visible to their owner (plus `AdminGroups` members). Add the `OwnerScoped`
65
+ * entity marker to *also* partition the table physically per identity.
66
+ */
67
+ export interface Owner extends DbConfig<{ owner: true }> {}
68
+
69
+ /**
70
+ * Marks the column that records the owning group (a verified `cognito:groups`
71
+ * value): `team: string & Group`. Compiles to `access.groupField` — rows are
72
+ * only visible to members of their group, and the dataset is **physically
73
+ * partitioned per group** by default (`groups/{name}/…` on object storage),
74
+ * so each group's records live in their own files. Add the `SharedStorage`
75
+ * entity marker to keep group rows in one shared table instead.
76
+ */
77
+ export interface Group extends DbConfig<{ group: true }> {}
56
78
 
57
79
  /** An integer column — a bare `number` property maps to `float`. */
58
80
  export type Int = number & DbConfig<{ int: true }>;