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
@@ -0,0 +1,83 @@
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
+
37
+ /** The permission gate carried in the type system. */
38
+ export interface PermissionOf<P extends string> {
39
+ readonly __rastackPermission?: P;
40
+ }
41
+
42
+ /** Every path requires a verified identity (this is also the default). */
43
+ export interface IsAuthenticated extends PermissionOf<"authenticated"> {}
44
+
45
+ /** Anonymous callers may read (list/retrieve); writes require identity. */
46
+ export interface IsAuthenticatedOrReadOnly extends PermissionOf<"authenticatedOrReadOnly"> {}
47
+
48
+ /** No gate — every caller may read and write. Opt-in, never the default. */
49
+ export interface IsPublic extends PermissionOf<"public"> {}
50
+
51
+ /**
52
+ * Token groups (`cognito:groups`) that bypass row-level security on this
53
+ * entity — the "is admin" role, driven from scopes: `implements
54
+ * AdminGroups<"staff" | "ops">`. Members see and act on every row.
55
+ */
56
+ export interface AdminGroups<G extends string> {
57
+ readonly __rastackAdminGroups?: G;
58
+ }
59
+
60
+ /** Storage scope carried in the type system. */
61
+ export interface ScopeOf<S extends string> {
62
+ readonly __rastackScope?: S;
63
+ }
64
+
65
+ /**
66
+ * Physically partition the entity's table per identity
67
+ * (`tenants/{sub}/…`) — the prefix object-storage IAM can fence per user.
68
+ * Combine with an `Owner` field brand.
69
+ */
70
+ export interface OwnerScoped extends ScopeOf<"owner"> {}
71
+
72
+ /**
73
+ * Physically partition the entity's table per group (`groups/{name}/…`).
74
+ * This is the default whenever a `Group` field brand is present — declare it
75
+ * only for emphasis or with a JSDoc-configured group field.
76
+ */
77
+ export interface GroupScoped extends ScopeOf<"group"> {}
78
+
79
+ /**
80
+ * Keep rows in one shared table (row-level filters still apply). Use to opt a
81
+ * `Group`-branded entity out of the default per-group physical partitioning.
82
+ */
83
+ export interface SharedStorage extends ScopeOf<"shared"> {}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `rastack/types` compatibility surface — one module aliasing the two
3
+ * split authoring subpaths: `rastack/db` (the `DbConfig` column brands) and
4
+ * `rastack/auth` (the entity-level auth markers + `Owner`/`Group` brands).
5
+ * Prefer those; everything stays re-exported here so existing imports keep
6
+ * working. The compiler maps all three specifiers (see `compile/program.ts`);
7
+ * the published package re-exports the same modules from `tools/types.ts`.
8
+ */
9
+
10
+ export * from "./db";
11
+ export * from "./markers";
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Tests for the `rastack/auth` core — the client-side mirror of the access
3
+ * rules `rastack-api-core` enforces (see `rust/rastack-api-core/tests/access.rs`
4
+ * for the server side of the same matrix). The semantics here must track the
5
+ * Rust `check_permission` / `enforced_owner` exactly: `can()` answers "would
6
+ * the server accept this request?", so the components can limit the paths they
7
+ * expose instead of surfacing 401s.
8
+ */
9
+
10
+ import {
11
+ anonymousAuth,
12
+ authFromClaims,
13
+ bypassesRls,
14
+ can,
15
+ canActOnRow,
16
+ isAdmin,
17
+ ownsRow,
18
+ policyFromManifest,
19
+ policyFromOpenApi,
20
+ trustedAuth,
21
+ userAuth,
22
+ type ResourcePolicy,
23
+ } from "../auth/core";
24
+
25
+ const rls: ResourcePolicy = {
26
+ permission: "authenticated",
27
+ access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
28
+ };
29
+
30
+ describe("rastack/auth — the permission gate (can)", () => {
31
+ it("defaults to authenticated — secure by default, like the server", () => {
32
+ expect(can(anonymousAuth(), {}, "list")).toBe(false);
33
+ expect(can(anonymousAuth(), undefined, "create")).toBe(false);
34
+ expect(can(userAuth("u1"), {}, "list")).toBe(true);
35
+ expect(can(userAuth("u1"), undefined, "delete")).toBe(true);
36
+ });
37
+
38
+ it("public admits everyone, for every action", () => {
39
+ const policy: ResourcePolicy = { permission: "public" };
40
+ for (const action of [
41
+ "list",
42
+ "retrieve",
43
+ "create",
44
+ "update",
45
+ "delete",
46
+ ] as const) {
47
+ expect(can(anonymousAuth(), policy, action)).toBe(true);
48
+ }
49
+ });
50
+
51
+ it("authenticatedOrReadOnly admits anonymous reads but not writes", () => {
52
+ const policy: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
53
+ expect(can(anonymousAuth(), policy, "list")).toBe(true);
54
+ expect(can(anonymousAuth(), policy, "retrieve")).toBe(true);
55
+ expect(can(anonymousAuth(), policy, "create")).toBe(false);
56
+ expect(can(anonymousAuth(), policy, "update")).toBe(false);
57
+ expect(can(anonymousAuth(), policy, "delete")).toBe(false);
58
+ expect(can(userAuth("u1"), policy, "delete")).toBe(true);
59
+ });
60
+
61
+ it("row-secured resources need a subject (the server answers 401 without one)", () => {
62
+ // Authenticated but subject-less — e.g. a machine token without `sub`.
63
+ const noSub = { authenticated: true, groups: [] };
64
+ expect(can(noSub, rls, "list")).toBe(false);
65
+ expect(can(userAuth("u1"), rls, "list")).toBe(true);
66
+ // A configured admin group bypasses row-level security.
67
+ expect(can({ ...noSub, groups: ["staff"] }, rls, "list")).toBe(true);
68
+ });
69
+
70
+ it("trusted contexts (dev without auth) pass everything", () => {
71
+ expect(can(trustedAuth(), rls, "delete")).toBe(true);
72
+ });
73
+
74
+ it("a missing identity gates nothing — the server stays the enforcer", () => {
75
+ expect(can(undefined, rls, "delete")).toBe(true);
76
+ expect(can(null, { permission: "authenticated" }, "create")).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe("rastack/auth — row-level security helpers", () => {
81
+ const mine = { id: 1, owner: "u1" };
82
+ const theirs = { id: 2, owner: "u2" };
83
+
84
+ it("owners own their rows; others don't", () => {
85
+ expect(ownsRow(userAuth("u1"), rls, mine)).toBe(true);
86
+ expect(ownsRow(userAuth("u1"), rls, theirs)).toBe(false);
87
+ });
88
+
89
+ it("admin groups and trusted contexts bypass the owner filter", () => {
90
+ expect(bypassesRls(userAuth("u1", ["staff"]), rls)).toBe(true);
91
+ expect(ownsRow(userAuth("u1", ["staff"]), rls, theirs)).toBe(true);
92
+ expect(ownsRow(trustedAuth(), rls, theirs)).toBe(true);
93
+ expect(bypassesRls(userAuth("u1", ["other"]), rls)).toBe(false);
94
+ });
95
+
96
+ it("unsecured resources are owned by everyone", () => {
97
+ expect(ownsRow(userAuth("u1"), {}, theirs)).toBe(true);
98
+ });
99
+
100
+ it("canActOnRow combines the gate and ownership", () => {
101
+ expect(canActOnRow(userAuth("u1"), rls, "update", mine)).toBe(true);
102
+ expect(canActOnRow(userAuth("u1"), rls, "update", theirs)).toBe(false);
103
+ expect(canActOnRow(anonymousAuth(), rls, "update", mine)).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("rastack/auth — group-owned datasets", () => {
108
+ // A group dataset: rows belong to a team, physically partitioned per group.
109
+ const grouped: ResourcePolicy = {
110
+ permission: "authenticated",
111
+ access: { groupField: "team", scope: "group", adminGroups: ["staff"] },
112
+ };
113
+ const blueRow = { id: 1, team: "blue" };
114
+
115
+ it("members read; identities in no group can read (empty) but never write", () => {
116
+ const dana = userAuth("dana", ["blue"]);
117
+ const loner = userAuth("lonnie", []);
118
+ expect(can(dana, grouped, "list")).toBe(true);
119
+ expect(can(dana, grouped, "create")).toBe(true);
120
+ // Reads outside any group just come back empty — but a write can never
121
+ // succeed, so the UI shouldn't offer it.
122
+ expect(can(loner, grouped, "list")).toBe(true);
123
+ expect(can(loner, grouped, "create")).toBe(false);
124
+ expect(can(loner, grouped, "delete")).toBe(false);
125
+ });
126
+
127
+ it("row membership mirrors the server's group guard", () => {
128
+ expect(ownsRow(userAuth("dana", ["blue"]), grouped, blueRow)).toBe(true);
129
+ expect(ownsRow(userAuth("erin", ["green"]), grouped, blueRow)).toBe(false);
130
+ expect(
131
+ canActOnRow(userAuth("erin", ["green"]), grouped, "update", blueRow),
132
+ ).toBe(false);
133
+ // adminGroups members act across groups.
134
+ expect(ownsRow(userAuth("sam", ["staff"]), grouped, blueRow)).toBe(true);
135
+ });
136
+
137
+ it("owner + group guards both apply when a policy carries both", () => {
138
+ const both: ResourcePolicy = {
139
+ access: { ownerField: "owner", groupField: "team" },
140
+ };
141
+ const row = { id: 1, owner: "dana", team: "blue" };
142
+ expect(ownsRow(userAuth("dana", ["blue"]), both, row)).toBe(true);
143
+ expect(ownsRow(userAuth("dana", ["green"]), both, row)).toBe(false);
144
+ expect(ownsRow(userAuth("erin", ["blue"]), both, row)).toBe(false);
145
+ });
146
+
147
+ it("isAdmin is the scope-driven admin role", () => {
148
+ expect(isAdmin(userAuth("sam", ["staff"]), grouped)).toBe(true);
149
+ expect(isAdmin(userAuth("dana", ["blue"]), grouped)).toBe(false);
150
+ expect(isAdmin(trustedAuth(), grouped)).toBe(true);
151
+ });
152
+ });
153
+
154
+ describe("rastack/auth — building an identity", () => {
155
+ it("authFromClaims reads sub and cognito:groups from a decoded token", () => {
156
+ const auth = authFromClaims({
157
+ sub: "u1",
158
+ "cognito:groups": ["staff", 42],
159
+ email: "x@y.z",
160
+ });
161
+ expect(auth).toEqual({ authenticated: true, sub: "u1", groups: ["staff"] });
162
+ expect(authFromClaims({}).authenticated).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("rastack/auth — policy lookup", () => {
167
+ const manifest = {
168
+ resources: [
169
+ { app: "airports", model: "airport", fields: [] },
170
+ {
171
+ app: "airports",
172
+ model: "itinerary",
173
+ fields: [],
174
+ permission: "authenticated",
175
+ access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
176
+ },
177
+ ],
178
+ };
179
+
180
+ it("policyFromManifest extracts permission + access", () => {
181
+ expect(policyFromManifest(manifest, "airports.itinerary")).toEqual(rls);
182
+ expect(
183
+ policyFromManifest(manifest, { app: "airports", model: "airport" }),
184
+ ).toEqual({});
185
+ expect(policyFromManifest(manifest, "airports.missing")).toBeUndefined();
186
+ // Accepts the JSON string form the provider may hold.
187
+ expect(
188
+ policyFromManifest(JSON.stringify(manifest), "airports.itinerary"),
189
+ ).toEqual(rls);
190
+ });
191
+
192
+ it("policyFromOpenApi reads the x-rastack-permission / x-rastack-access stamps", () => {
193
+ const doc = {
194
+ paths: {
195
+ "/api/airports/v1/itinerary/": {
196
+ get: {
197
+ operationId: "listItinerary",
198
+ "x-rastack-permission": "authenticated",
199
+ "x-rastack-access": rls.access,
200
+ },
201
+ },
202
+ },
203
+ };
204
+ expect(policyFromOpenApi(doc, "airports.itinerary")).toEqual(rls);
205
+ expect(policyFromOpenApi(doc, "airports.airport")).toBeUndefined();
206
+ });
207
+ });
@@ -81,7 +81,10 @@ describe("rastack compile — resource analysis & FK inference", () => {
81
81
 
82
82
  const airportField = terminal.fields.find((f) => f.name === "airport");
83
83
  expect(airportField?.type).toBe("fk");
84
- expect(airportField?.relation).toEqual({ app: "airports", model: "airport" });
84
+ expect(airportField?.relation).toEqual({
85
+ app: "airports",
86
+ model: "airport",
87
+ });
85
88
 
86
89
  expect(terminal.relations).toEqual([
87
90
  { field: "airport", target: "airports.airport", kind: "fk" },
@@ -132,7 +135,9 @@ describe("rastack compile — resource analysis & FK inference", () => {
132
135
  });
133
136
 
134
137
  // Resources without an access block stay unstamped.
135
- expect(findResource(resources, "airports", "airport").access).toBeUndefined();
138
+ expect(
139
+ findResource(resources, "airports", "airport").access,
140
+ ).toBeUndefined();
136
141
  });
137
142
 
138
143
  it("reports no collisions for a valid graph", () => {
@@ -176,9 +181,9 @@ describe("rastack compile — OpenAPI emission", () => {
176
181
  // Local-mode sync extensions, confirmed on update.
177
182
  const flightList = doc.paths["/api/airports/v1/flight/"];
178
183
  expect(flightList.get["x-rastack-sync"]).toBe("local");
179
- expect(doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"]).toBe(
180
- "confirmed",
181
- );
184
+ expect(
185
+ doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"],
186
+ ).toBe("confirmed");
182
187
  expect(doc.paths["/api/airports/v1/gate/"].get["x-rastack-sync-load"]).toBe(
183
188
  "lazy",
184
189
  );
@@ -186,19 +191,25 @@ describe("rastack compile — OpenAPI emission", () => {
186
191
  doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
187
192
  ).toBe(true);
188
193
 
189
- // Access-controlled resource carries the access extension on every op.
194
+ // Access-controlled resource carries the full policy on every op — the
195
+ // permission gate plus the access block `rastack/auth` mirrors in the UI.
190
196
  const itineraryList = doc.paths["/api/airports/v1/itinerary/"];
197
+ expect(itineraryList.get["x-rastack-permission"]).toBe("authenticated");
191
198
  expect(itineraryList.get["x-rastack-access"]).toEqual({
192
199
  ownerField: "owner",
193
200
  scope: "owner",
201
+ adminGroups: ["staff"],
194
202
  });
195
203
  expect(
196
204
  doc.paths["/api/airports/v1/itinerary/{id}/"].delete["x-rastack-access"],
197
- ).toEqual({ ownerField: "owner", scope: "owner" });
205
+ ).toEqual({ ownerField: "owner", scope: "owner", adminGroups: ["staff"] });
198
206
  // Unscoped resources stay unstamped.
199
207
  expect(
200
208
  doc.paths["/api/airports/v1/airport/"].get["x-rastack-access"],
201
209
  ).toBeUndefined();
210
+ expect(
211
+ doc.paths["/api/airports/v1/airport/"].get["x-rastack-permission"],
212
+ ).toBeUndefined();
202
213
  });
203
214
 
204
215
  it("round-trips through the existing hooks codegen", async () => {
@@ -16,6 +16,7 @@ import {
16
16
  resolveFieldSpecs,
17
17
  } from "../components/auto-form/fields";
18
18
  import { buildAutoFormModel } from "../components/auto-form/use-auto-form";
19
+ import { anonymousAuth, userAuth, type ResourcePolicy } from "../auth/core";
19
20
  import type {
20
21
  TableQuery,
21
22
  AutoFormLike,
@@ -406,3 +407,118 @@ describe("buildAutoFormModel — wiring a form hook into field descriptors", ()
406
407
  expect(transition).toHaveBeenCalledWith("board");
407
408
  });
408
409
  });
410
+
411
+ // Auth handling built into the components: the models mirror the server's
412
+ // `permission` gate + row-level security (rastack/auth) so the UI never
413
+ // offers a write the API would reject.
414
+ describe("component models — authorization gating", () => {
415
+ const rls: ResourcePolicy = {
416
+ permission: "authenticated",
417
+ access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
418
+ };
419
+ const readOnly: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
420
+
421
+ const makeField = (): AutoFormField =>
422
+ ({
423
+ value: "",
424
+ error: "",
425
+ type: "text",
426
+ isChecked: false,
427
+ isError: false,
428
+ isDisabled: false,
429
+ isRequired: false,
430
+ setValue: jest.fn(),
431
+ setTouched: jest.fn(),
432
+ }) as unknown as AutoFormField;
433
+
434
+ const form: AutoFormLike = {
435
+ formValues: { owner: "u1", notes: "hi" },
436
+ getFormField: () => makeField(),
437
+ onSubmit: jest.fn(),
438
+ resetForm: jest.fn(),
439
+ isSubmitDisabled: false,
440
+ isSubmitting: false,
441
+ errors: {},
442
+ };
443
+
444
+ it("auto-form: anonymous writes are blocked by the permission gate", () => {
445
+ const model = buildAutoFormModel(form, {
446
+ auth: anonymousAuth(),
447
+ policy: readOnly,
448
+ action: "update",
449
+ });
450
+ expect(model.canWrite).toBe(false);
451
+ expect(model.canSubmit).toBe(false);
452
+
453
+ const asUser = buildAutoFormModel(form, {
454
+ auth: userAuth("u1"),
455
+ policy: readOnly,
456
+ });
457
+ expect(asUser.canWrite).toBe(true);
458
+ });
459
+
460
+ it("auto-form: no identity wired up ⇒ ungated (server stays the enforcer)", () => {
461
+ const model = buildAutoFormModel(form, { policy: rls });
462
+ expect(model.canWrite).toBe(true);
463
+ expect(model.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
464
+ });
465
+
466
+ it("auto-form: hides the server-stamped owner field, except for RLS bypassers", () => {
467
+ const model = buildAutoFormModel(form, {
468
+ auth: userAuth("u1"),
469
+ policy: rls,
470
+ });
471
+ expect(model.fields.map((f) => f.name)).toEqual(["notes"]);
472
+
473
+ const staff = buildAutoFormModel(form, {
474
+ auth: userAuth("u1", ["staff"]),
475
+ policy: rls,
476
+ });
477
+ expect(staff.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
478
+
479
+ // An explicit field list is the author's call — never overridden.
480
+ const explicit = buildAutoFormModel(form, {
481
+ auth: userAuth("u1"),
482
+ policy: rls,
483
+ fields: ["owner", "notes"],
484
+ });
485
+ expect(explicit.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
486
+ });
487
+
488
+ it("data-table: canRead / can / canActOnRow mirror the server's rules", () => {
489
+ const query = {
490
+ data: [
491
+ { id: 1, owner: "u1" },
492
+ { id: 2, owner: "u2" },
493
+ ],
494
+ isLoading: false,
495
+ } as TableQuery<{ id: number; owner: string }>;
496
+
497
+ const anon = buildDataTableModel(query, {
498
+ auth: anonymousAuth(),
499
+ policy: rls,
500
+ });
501
+ expect(anon.canRead).toBe(false);
502
+
503
+ const mine = buildDataTableModel(query, {
504
+ auth: userAuth("u1"),
505
+ policy: rls,
506
+ });
507
+ expect(mine.canRead).toBe(true);
508
+ expect(mine.can("delete")).toBe(true);
509
+ expect(mine.canActOnRow("delete", { id: 1, owner: "u1" })).toBe(true);
510
+ expect(mine.canActOnRow("delete", { id: 2, owner: "u2" })).toBe(false);
511
+
512
+ const anonPublicRead = buildDataTableModel(query, {
513
+ auth: anonymousAuth(),
514
+ policy: readOnly,
515
+ });
516
+ expect(anonPublicRead.canRead).toBe(true);
517
+ expect(anonPublicRead.can("create")).toBe(false);
518
+
519
+ // No identity wired up ⇒ everything stays enabled.
520
+ const ungated = buildDataTableModel(query, { policy: rls });
521
+ expect(ungated.canRead).toBe(true);
522
+ expect(ungated.can("delete")).toBe(true);
523
+ });
524
+ });
@@ -17,6 +17,7 @@ import {
17
17
  const workRoot = join(__dirname, "entities-fixtures");
18
18
  const appDir = join(workRoot, "app");
19
19
  const classesDir = join(workRoot, "classes");
20
+ const authDir = join(workRoot, "auth");
20
21
  const cycleDir = join(workRoot, "cycles");
21
22
  const hardCycleDir = join(workRoot, "hard-cycles");
22
23
  const mixedDir = join(workRoot, "mixed");
@@ -82,7 +83,7 @@ import {
82
83
  PrimaryKey,
83
84
  Unique,
84
85
  UUID,
85
- } from "rastack/types";
86
+ } from "rastack/db";
86
87
  import { Terminal } from "./airports";
87
88
 
88
89
  /** A reusable column config — "extend DbConfig once, use it everywhere". */
@@ -184,6 +185,60 @@ export class BoardingService {
184
185
  export class SplashScreen extends BoardingService {}
185
186
  `;
186
187
 
188
+ const AUTH_TS = `
189
+ import {
190
+ AdminGroups,
191
+ Group,
192
+ IsAuthenticated,
193
+ IsAuthenticatedOrReadOnly,
194
+ Owner,
195
+ OwnerScoped,
196
+ SharedStorage,
197
+ } from "rastack/auth";
198
+
199
+ // Per-user rows: RLS on owner, physically partitioned per identity. The
200
+ // implements clause is policy, not behaviour — still a plain data class.
201
+ export class Itinerary
202
+ implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped
203
+ {
204
+ owner!: string & Owner;
205
+ notes!: string;
206
+ }
207
+
208
+ // A group-owned dataset — physically partitioned per group by default.
209
+ export class Report
210
+ implements IsAuthenticatedOrReadOnly, AdminGroups<"staff" | "ops">
211
+ {
212
+ team!: string & Group;
213
+ title!: string;
214
+ }
215
+
216
+ // Group RLS, but explicitly opted out of per-group storage.
217
+ export class Note implements SharedStorage {
218
+ team!: string & Group;
219
+ body!: string;
220
+ }
221
+
222
+ // Interfaces attach the same markers with extends.
223
+ export interface Memo extends IsAuthenticated, AdminGroups<"staff"> {
224
+ body: string;
225
+ }
226
+
227
+ /** JSDoc equivalents, tag-for-marker.
228
+ * @rastackResource
229
+ * @rastackPermission public
230
+ * @rastackAdminGroups staff, ops
231
+ */
232
+ export interface Bulletin {
233
+ /** @owner */
234
+ author: string;
235
+ text: string;
236
+ }
237
+
238
+ declare function useEntityList<T>(name: string): T[];
239
+ export const m = useEntityList<Memo>("memos");
240
+ `;
241
+
187
242
  function findResource(
188
243
  resources: ResourceModel[],
189
244
  app: string,
@@ -202,6 +257,8 @@ beforeAll(() => {
202
257
  writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
203
258
  mkdirSync(classesDir, { recursive: true });
204
259
  writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
260
+ mkdirSync(authDir, { recursive: true });
261
+ writeFileSync(join(authDir, "workspace.ts"), AUTH_TS);
205
262
  mkdirSync(cycleDir, { recursive: true });
206
263
  writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
207
264
  mkdirSync(hardCycleDir, { recursive: true });
@@ -254,7 +311,10 @@ describe("interface entities — datatypes from the type system", () => {
254
311
  expect(field("onTime")).toMatchObject({ type: "bool" });
255
312
  expect(field("departure")).toMatchObject({ type: "datetime" });
256
313
  expect(field("notes")).toMatchObject({ type: "string", null: true });
257
- expect(field("status")).toMatchObject({ type: "string", default: "scheduled" });
314
+ expect(field("status")).toMatchObject({
315
+ type: "string",
316
+ default: "scheduled",
317
+ });
258
318
  });
259
319
 
260
320
  it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
@@ -270,13 +330,17 @@ describe("interface entities — datatypes from the type system", () => {
270
330
  it("reads column config from DbConfig brand types", () => {
271
331
  const { resources } = analyzeProject(appDir);
272
332
  const passenger = findResource(resources, "passengers", "passenger");
273
- const field = (name: string) => passenger.fields.find((f) => f.name === name);
333
+ const field = (name: string) =>
334
+ passenger.fields.find((f) => f.name === name);
274
335
 
275
336
  expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
276
337
  expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
277
338
  expect(field("age")).toMatchObject({ type: "int" });
278
339
  expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
279
- expect(field("status")).toMatchObject({ type: "string", default: "active" });
340
+ expect(field("status")).toMatchObject({
341
+ type: "string",
342
+ default: "active",
343
+ });
280
344
  expect(field("terminal")).toMatchObject({
281
345
  type: "fk",
282
346
  null: true,
@@ -305,7 +369,8 @@ describe("interface entities — datatypes from the type system", () => {
305
369
  expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
306
370
  expect(
307
371
  diagnostics.some(
308
- (d) => d.severity === "warning" && d.message.includes("flights.flight.tags"),
372
+ (d) =>
373
+ d.severity === "warning" && d.message.includes("flights.flight.tags"),
309
374
  ),
310
375
  ).toBe(true);
311
376
  });
@@ -406,10 +471,62 @@ describe("class entities — plain data classes", () => {
406
471
  });
407
472
  });
408
473
 
474
+ describe("entity access policy — auth as types", () => {
475
+ it("reads permission / adminGroups / scope markers off a class's implements clause", () => {
476
+ const { resources } = analyzeProject(authDir);
477
+ const itinerary = findResource(resources, "workspace", "itinerary");
478
+ expect(itinerary.permission).toBe("authenticated");
479
+ expect(itinerary.access).toEqual({
480
+ ownerField: "owner",
481
+ scope: "owner",
482
+ adminGroups: ["staff"],
483
+ });
484
+ });
485
+
486
+ it("a Group field makes a group-owned dataset — partitioned per group by default", () => {
487
+ const { resources } = analyzeProject(authDir);
488
+ const report = findResource(resources, "workspace", "report");
489
+ expect(report.permission).toBe("authenticatedOrReadOnly");
490
+ expect(report.access).toEqual({
491
+ groupField: "team",
492
+ scope: "group",
493
+ adminGroups: ["ops", "staff"],
494
+ });
495
+ // The group column is an ordinary string field in the schema.
496
+ expect(report.fields.find((f) => f.name === "team")?.type).toBe("string");
497
+ });
498
+
499
+ it("SharedStorage opts a Group dataset out of physical partitioning", () => {
500
+ const { resources } = analyzeProject(authDir);
501
+ const note = findResource(resources, "workspace", "note");
502
+ expect(note.access).toEqual({ groupField: "team", scope: "shared" });
503
+ });
504
+
505
+ it("interfaces attach markers via extends; markers add no columns", () => {
506
+ const { resources } = analyzeProject(authDir);
507
+ const memo = findResource(resources, "workspace", "memo");
508
+ expect(memo.permission).toBe("authenticated");
509
+ expect(memo.access).toEqual({ adminGroups: ["staff"] });
510
+ expect(memo.fields.map((f) => f.name)).toEqual(["body"]);
511
+ });
512
+
513
+ it("JSDoc tags mirror every marker (@rastackPermission/@rastackAdminGroups/@owner)", () => {
514
+ const { resources } = analyzeProject(authDir);
515
+ const bulletin = findResource(resources, "workspace", "bulletin");
516
+ expect(bulletin.permission).toBe("public");
517
+ expect(bulletin.access).toEqual({
518
+ ownerField: "author",
519
+ adminGroups: ["staff", "ops"],
520
+ });
521
+ });
522
+ });
523
+
409
524
  describe("interface entities — circular dependencies", () => {
410
525
  it("reports a breakable cycle (optional self-FK) as a warning", () => {
411
526
  const { diagnostics } = analyzeProject(cycleDir);
412
- const cycle = diagnostics.find((d) => d.message.includes("Circular dependency"));
527
+ const cycle = diagnostics.find((d) =>
528
+ d.message.includes("Circular dependency"),
529
+ );
413
530
  expect(cycle).toBeDefined();
414
531
  expect(cycle!.severity).toBe("warning");
415
532
  expect(cycle!.message).toContain("org.employee → org.employee");