rastack 0.0.58 → 0.0.60

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 (83) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +190 -0
  3. package/dist/deploy/ci.js +2 -2
  4. package/dist/deploy/io.js +2 -2
  5. package/dist/dev/harness.d.ts +1 -1
  6. package/dist/dev/harness.js +1 -2
  7. package/dist/external/cache.d.ts +28 -0
  8. package/dist/external/cache.js +35 -0
  9. package/dist/external/envelope.d.ts +35 -0
  10. package/dist/external/envelope.js +48 -0
  11. package/dist/external/graphql.d.ts +25 -0
  12. package/dist/external/graphql.js +78 -0
  13. package/dist/external/handler.d.ts +52 -0
  14. package/dist/external/handler.js +139 -0
  15. package/dist/external/index.d.ts +19 -0
  16. package/dist/external/index.js +38 -0
  17. package/dist/external/lambda.d.ts +49 -0
  18. package/dist/external/lambda.js +86 -0
  19. package/dist/external/rest.d.ts +17 -0
  20. package/dist/external/rest.js +46 -0
  21. package/dist/external/route.d.ts +12 -0
  22. package/dist/external/route.js +33 -0
  23. package/dist/external/service.d.ts +50 -0
  24. package/dist/external/service.js +59 -0
  25. package/dist/external/types.d.ts +107 -0
  26. package/dist/external/types.js +22 -0
  27. package/dist/external/util.d.ts +16 -0
  28. package/dist/external/util.js +58 -0
  29. package/dist/rastack-dev.js +16 -1
  30. package/dist/rastack-wasm-build.d.ts +1 -1
  31. package/dist/rastack-wasm-build.js +6 -6
  32. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  33. package/external.ts +10 -0
  34. package/hooks/query/delete.ts +12 -11
  35. package/hooks/query/fetch.ts +6 -8
  36. package/hooks/query/interfaces.ts +1 -4
  37. package/hooks/query/list.ts +16 -9
  38. package/hooks/query/update.ts +8 -6
  39. package/package.json +24 -2
  40. package/scripts/copy-templates.js +1 -1
  41. package/scripts/copy-wasm.js +1 -1
  42. package/scripts/dev-install.mjs +63 -0
  43. package/src/deploy/ci.ts +2 -2
  44. package/src/deploy/io.ts +2 -2
  45. package/src/dev/harness.ts +1 -2
  46. package/src/external/cache.ts +53 -0
  47. package/src/external/envelope.ts +78 -0
  48. package/src/external/graphql.ts +98 -0
  49. package/src/external/handler.ts +207 -0
  50. package/src/external/index.ts +25 -0
  51. package/src/external/lambda.ts +108 -0
  52. package/src/external/rest.ts +60 -0
  53. package/src/external/route.ts +33 -0
  54. package/src/external/service.ts +86 -0
  55. package/src/external/types.ts +121 -0
  56. package/src/external/util.ts +57 -0
  57. package/src/rastack-dev.ts +17 -1
  58. package/src/rastack-wasm-build.ts +6 -6
  59. package/jest.config.cjs +0 -12
  60. package/test/auth.spec.ts +0 -207
  61. package/test/cache.spec.ts +0 -263
  62. package/test/cognito.spec.ts +0 -281
  63. package/test/compile.spec.ts +0 -240
  64. package/test/components.spec.ts +0 -524
  65. package/test/csv-schema.spec.ts +0 -143
  66. package/test/deploy.spec.ts +0 -571
  67. package/test/dev.spec.ts +0 -414
  68. package/test/entities.spec.ts +0 -597
  69. package/test/import.spec.ts +0 -241
  70. package/test/plugin.spec.ts +0 -315
  71. package/test/runtime-manifest.spec.ts +0 -309
  72. package/test/schema-entities.spec.ts +0 -688
  73. package/test/tokens.spec.ts +0 -302
  74. package/test/transitions.spec.ts +0 -372
  75. package/test/typed-hooks.spec.ts +0 -412
  76. package/test/update.spec.ts +0 -152
  77. package/test/validate.spec.ts +0 -319
  78. package/tsconfig.json +0 -27
  79. package/wasm/package.json +0 -4
  80. package/wasm/rastack_wasm.d.ts +0 -100
  81. package/wasm/rastack_wasm.js +0 -745
  82. package/wasm/rastack_wasm_bg.wasm +0 -0
  83. package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The mini local proxy service — the "lambda you run while iterating".
3
+ *
4
+ * `rastack dev` already serves the app + admin over WASM for the stack's *own*
5
+ * resources. This adds the other half for **external** resources: a request
6
+ * handler it mounts under `/api/`, so `useData("github.repo")` works locally,
7
+ * cached, before any real Lambda is deployed. It is a thin adapter over the pure
8
+ * {@link handleExternalRequest} core — parse the path, resolve dev auth from env
9
+ * vars, run the handler, serialise JSON — reused verbatim by the deployed Lambda
10
+ * (which swaps the env-var auth for Secrets Manager and the Map cache for a
11
+ * shared store).
12
+ */
13
+
14
+ import { memoryCache, type ProxyCache } from "./cache";
15
+ import { handleExternalRequest, type HandlerDeps } from "./handler";
16
+ import { parseApiPath } from "./route";
17
+ import type { FetchLike, ResourceBinding, ResourceResolver } from "./types";
18
+
19
+ export interface ExternalService {
20
+ /** True when the path is an `/api/{app}/v1/{model}/` route this service owns. */
21
+ handles(url: string): boolean;
22
+ /**
23
+ * Handle a request. Returns the HTTP status, JSON body string, and whether it
24
+ * was a cache hit (for a dev-log line). Returns `null` when the path isn't an
25
+ * API route, so the caller falls through to its other handlers.
26
+ */
27
+ serve(
28
+ url: string,
29
+ ): Promise<{ status: number; json: string; cached: boolean } | null>;
30
+ }
31
+
32
+ export interface ServiceOptions {
33
+ bindings: ResourceBinding[];
34
+ /** Injected fetch; defaults to the global `fetch` (Node 18+/browsers). */
35
+ fetch?: FetchLike;
36
+ cache?: ProxyCache;
37
+ now?: () => number;
38
+ /** Per-app upstream auth headers. In dev, typically read from `process.env`. */
39
+ authHeaders?: HandlerDeps["authHeaders"];
40
+ /** Named resolvers for `resolver`-transport bindings (e.g. an AWS resolver). */
41
+ resolvers?: Record<string, ResourceResolver>;
42
+ }
43
+
44
+ /**
45
+ * Read a dev token for an app from the environment, e.g. `GITHUB_TOKEN`,
46
+ * `EXPO_TOKEN`. Returns a Bearer header when present, else nothing (public
47
+ * endpoints still work; private ones 401 with a clear upstream message). This is
48
+ * the *only* place a raw token is read locally — production reads Secrets Manager
49
+ * in the Lambda instead, and no token is ever sent to the client.
50
+ */
51
+ export function envAuthHeaders(
52
+ env: Record<string, string | undefined> = process.env,
53
+ ): NonNullable<HandlerDeps["authHeaders"]> {
54
+ return (app): Record<string, string> => {
55
+ const token = env[`${app.toUpperCase()}_TOKEN`];
56
+ return token ? { Authorization: `Bearer ${token}` } : {};
57
+ };
58
+ }
59
+
60
+ /** Create the mini external-API service `rastack dev` mounts. */
61
+ export function createExternalService(opts: ServiceOptions): ExternalService {
62
+ const deps: HandlerDeps = {
63
+ fetch: opts.fetch ?? ((globalThis as { fetch?: FetchLike }).fetch as FetchLike),
64
+ cache: opts.cache ?? memoryCache(),
65
+ now: opts.now ?? (() => Date.now()),
66
+ authHeaders: opts.authHeaders ?? envAuthHeaders(),
67
+ resolvers: opts.resolvers,
68
+ };
69
+ if (!deps.fetch) {
70
+ throw new Error("createExternalService needs a `fetch` (none on globalThis).");
71
+ }
72
+
73
+ return {
74
+ handles: (url) => parseApiPath(url) !== null,
75
+ async serve(url) {
76
+ const req = parseApiPath(url);
77
+ if (!req) return null;
78
+ const result = await handleExternalRequest(req, opts.bindings, deps);
79
+ return {
80
+ status: result.status,
81
+ json: JSON.stringify(result.body),
82
+ cached: result.cached,
83
+ };
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * External-API bindings — how a rastack `app.model` is served from an upstream
3
+ * REST or GraphQL API.
4
+ *
5
+ * The stack's own resources are served by the Rust API / WASM engine over the
6
+ * `/api/{app}/v1/{model}/` contract. An **external** resource (a GitHub repo, an
7
+ * Expo project, an AWS stack) is served by the proxy instead: it speaks the same
8
+ * contract to the client, and translates each request into an upstream call,
9
+ * normalises the response into rastack's DRF-shaped envelope, and caches it.
10
+ *
11
+ * One {@link ResourceBinding} describes that translation for a single model. The
12
+ * proxy core ({@link ./handler}) is a pure function of a request + the bindings +
13
+ * an injected `fetch`/cache, so the *same* logic runs in the deployed Lambda and
14
+ * in the mini service inside `rastack dev`.
15
+ */
16
+
17
+ /** A minimal `fetch` surface — the global `fetch` satisfies it; tests fake it. */
18
+ export interface FetchResponse {
19
+ status: number;
20
+ headers: { get(name: string): string | null };
21
+ json(): Promise<unknown>;
22
+ text(): Promise<string>;
23
+ }
24
+ export type FetchLike = (
25
+ url: string,
26
+ init?: {
27
+ method?: string;
28
+ headers?: Record<string, string>;
29
+ body?: string;
30
+ },
31
+ ) => Promise<FetchResponse>;
32
+
33
+ /** Common binding fields, independent of transport. */
34
+ interface BindingBase {
35
+ /** The rastack app namespace, e.g. `"github"`. */
36
+ app: string;
37
+ /** The rastack model, the `{model}` in `/api/{app}/v1/{model}/`. */
38
+ model: string;
39
+ /** The upstream field that is the row's identity. Default `"id"`. */
40
+ primaryKey?: string;
41
+ /** Cache TTL in ms for this resource's responses. Default 60_000. */
42
+ ttlMs?: number;
43
+ /** Whitelist of upstream fields to project into each row (default: all). */
44
+ fields?: string[];
45
+ /** Static headers merged into every upstream request (auth is separate). */
46
+ headers?: Record<string, string>;
47
+ }
48
+
49
+ /** Serve a model from an upstream REST endpoint. */
50
+ export interface RestBinding extends BindingBase {
51
+ transport: "rest";
52
+ /** Upstream origin, e.g. `"https://api.github.com"`. */
53
+ baseUrl: string;
54
+ /** Path to the list, e.g. `"/user/repos"`. */
55
+ listPath: string;
56
+ /** Path to a detail; `{id}` is substituted, e.g. `"/repositories/{id}"`. */
57
+ detailPath?: string;
58
+ /**
59
+ * Map rastack list params (`page`, `page_size`, `search`, `order_by`) to the
60
+ * upstream's own names (`page`, `per_page`, `q`, `sort`). Unmapped params are
61
+ * dropped, so the upstream never sees rastack-only query keys.
62
+ */
63
+ queryMap?: Partial<Record<"page" | "page_size" | "search" | "order_by", string>>;
64
+ /** Dot path to the array inside the list response (default: the body *is* the array). */
65
+ itemsPath?: string;
66
+ }
67
+
68
+ /** Serve a model from an upstream GraphQL endpoint. */
69
+ export interface GraphQLBinding extends BindingBase {
70
+ transport: "graphql";
71
+ /** The GraphQL endpoint URL, e.g. `"https://api.expo.dev/graphql"`. */
72
+ baseUrl: string;
73
+ /** The list query. Variables are filled from {@link variableMap}. */
74
+ listQuery: string;
75
+ /** Dot path into `data` to the array, e.g. `"me.accounts.0.apps"`. */
76
+ itemsPath: string;
77
+ /** Optional detail query; `$id` variable is bound to the requested id. */
78
+ detailQuery?: string;
79
+ /** Dot path into `data` to the detail object. */
80
+ detailPath?: string;
81
+ /** Map rastack params to GraphQL variable names (e.g. `page_size` → `first`). */
82
+ variableMap?: Partial<Record<"page" | "page_size" | "search" | "order_by" | "id", string>>;
83
+ /** Static variables merged into every query. */
84
+ variables?: Record<string, unknown>;
85
+ }
86
+
87
+ /**
88
+ * Serve a model from an injected resolver function rather than a plain HTTP
89
+ * call — for upstreams that need an SDK/signing the proxy core shouldn't import
90
+ * (AWS SigV4, say). The named resolver is provided via `HandlerDeps.resolvers`,
91
+ * so the framework stays dependency-free while the app plugs in, e.g., an AWS
92
+ * CloudFormation resolver. Still consumed through the normal `useData` contract.
93
+ */
94
+ export interface ResolverBinding extends BindingBase {
95
+ transport: "resolver";
96
+ /** Key into `HandlerDeps.resolvers` for the function that produces the data. */
97
+ resolver: string;
98
+ }
99
+
100
+ export type ResourceBinding = RestBinding | GraphQLBinding | ResolverBinding;
101
+
102
+ /**
103
+ * A resolver produces the upstream data for a {@link ResolverBinding}: an array
104
+ * for a list request, or a single object for a detail request. Normalisation
105
+ * into the rastack envelope is done by the core, exactly as for HTTP transports.
106
+ */
107
+ export type ResourceResolver = (args: {
108
+ app: string;
109
+ model: string;
110
+ id?: string;
111
+ query: Record<string, string[]>;
112
+ }) => Promise<unknown>;
113
+
114
+ /** Look a binding up by `app`/`model`. */
115
+ export function findBinding(
116
+ bindings: ResourceBinding[],
117
+ app: string,
118
+ model: string,
119
+ ): ResourceBinding | undefined {
120
+ return bindings.find((b) => b.app === app && b.model === model);
121
+ }
@@ -0,0 +1,57 @@
1
+ /** Small pure helpers shared by the proxy core. */
2
+
3
+ /**
4
+ * Read a value at a dot path (`"me.accounts.0.apps"`) out of a JSON value.
5
+ * Numeric segments index arrays. Returns `undefined` for any missing hop, so
6
+ * callers can default gracefully rather than throw on a shape they didn't get.
7
+ */
8
+ export function getPath(value: unknown, path: string | undefined): unknown {
9
+ if (!path) return value;
10
+ let cur: unknown = value;
11
+ for (const segment of path.split(".")) {
12
+ if (cur == null) return undefined;
13
+ if (Array.isArray(cur)) {
14
+ const idx = Number(segment);
15
+ cur = Number.isInteger(idx) ? cur[idx] : undefined;
16
+ } else if (typeof cur === "object") {
17
+ cur = (cur as Record<string, unknown>)[segment];
18
+ } else {
19
+ return undefined;
20
+ }
21
+ }
22
+ return cur;
23
+ }
24
+
25
+ /** Keep only `fields` on a row (plus always its `id`). No-op when unset. */
26
+ export function projectFields(
27
+ row: Record<string, unknown>,
28
+ fields: string[] | undefined,
29
+ ): Record<string, unknown> {
30
+ if (!fields || !fields.length) return row;
31
+ const out: Record<string, unknown> = {};
32
+ if ("id" in row) out.id = row.id;
33
+ for (const f of fields) if (f in row) out[f] = row[f];
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Ensure a row carries an `id` (the rastack detail-URL key). If the upstream's
39
+ * identity lives under a different `primaryKey`, mirror it onto `id`.
40
+ */
41
+ export function ensureId(
42
+ row: Record<string, unknown>,
43
+ primaryKey: string,
44
+ ): Record<string, unknown> {
45
+ if (primaryKey !== "id" && row.id === undefined && row[primaryKey] !== undefined) {
46
+ return { ...row, id: row[primaryKey] };
47
+ }
48
+ return row;
49
+ }
50
+
51
+ /** First value for a key in a parsed query (`{ page: ["2"] }` → `"2"`). */
52
+ export function firstParam(
53
+ query: Record<string, string[]>,
54
+ key: string,
55
+ ): string | undefined {
56
+ return query[key]?.[0];
57
+ }
@@ -193,7 +193,12 @@ function startMetro(cwd: string, port: number): ChildProcess {
193
193
  EXPO_NO_TELEMETRY: "1",
194
194
  };
195
195
  delete env.CI;
196
- // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
196
+ // `shell:true` is deliberate: it keeps the npx / npx.cmd split portable across
197
+ // POSIX and Windows. The only interpolated value is `port`, a number resolved
198
+ // by findFreePort (never user-controlled), so there is no shell-injection
199
+ // surface — suppress the spawn-shell-true audit rule (and its child-process
200
+ // sibling) here rather than break Windows portability.
201
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process, javascript.lang.security.audit.spawn-shell-true.spawn-shell-true
197
202
  const child = spawn(`npx expo start --web --port ${port}`, {
198
203
  cwd,
199
204
  env,
@@ -321,6 +326,17 @@ async function runMetroMode(args: DevArgs, cwd: string): Promise<void> {
321
326
  server.on("upgrade", (req, socket, head) =>
322
327
  proxyUpgrade(req, socket as net.Socket, head, metroPort),
323
328
  );
329
+ server.on("error", (err: NodeJS.ErrnoException) => {
330
+ if (err.code === "EADDRINUSE") {
331
+ console.error(
332
+ `\n ✗ Port ${args.port} is already in use — is \`rastack dev\` already running?\n` +
333
+ ` Stop it, or pick another port: rastack dev --port <n>\n`,
334
+ );
335
+ } else {
336
+ console.error(`\n ✗ dev server error: ${err.message}`);
337
+ }
338
+ stop(1);
339
+ });
324
340
 
325
341
  server.listen(args.port, () => {
326
342
  const url = `http://localhost:${args.port}`;
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Pipeline:
8
8
  * 1. cargo build -p rastack-wasm --release --target wasm32-unknown-unknown
9
- * 2. wasm-bindgen --target web → tools/wasm/rastack_wasm.js + rastack_wasm_bg.wasm
9
+ * 2. wasm-bindgen --target web → wasm/rastack_wasm.js + rastack_wasm_bg.wasm
10
10
  * 3. wasm-opt -Oz (if available) to shrink the binary
11
11
  *
12
12
  * This is invoked by the release CI (to bundle a prebuilt engine into the npm
@@ -20,9 +20,9 @@ import { spawnSync } from "child_process";
20
20
  import fs from "fs";
21
21
  import path from "path";
22
22
 
23
- const repoRoot = path.resolve(__dirname, "..", "..");
24
- const rustDir = path.join(repoRoot, "rust");
25
- const outDir = path.join(repoRoot, "tools", "wasm");
23
+ const repoRoot = path.resolve(__dirname, "..");
24
+ const rustDir = path.join(repoRoot, "engine");
25
+ const outDir = path.join(repoRoot, "wasm");
26
26
  const wasmPath = path.join(
27
27
  rustDir,
28
28
  "target",
@@ -78,7 +78,7 @@ export function buildWasm(): void {
78
78
  rustDir,
79
79
  );
80
80
 
81
- console.log("→ wasm-bindgen → tools/wasm/");
81
+ console.log("→ wasm-bindgen → wasm/");
82
82
  run("wasm-bindgen", [
83
83
  "--target",
84
84
  "web",
@@ -105,7 +105,7 @@ export function buildWasm(): void {
105
105
  }
106
106
 
107
107
  const size = fs.statSync(path.join(outDir, "rastack_wasm_bg.wasm")).size;
108
- console.log(`✓ wasm engine built: tools/wasm/ (${(size / 1024).toFixed(0)} KB)`);
108
+ console.log(`✓ wasm engine built: wasm/ (${(size / 1024).toFixed(0)} KB)`);
109
109
  }
110
110
 
111
111
  if (require.main === module) {
package/jest.config.cjs DELETED
@@ -1,12 +0,0 @@
1
- module.exports = {
2
- preset: 'ts-jest',
3
- testEnvironment: 'node',
4
- testMatch: ['**/test/**/*.spec.ts'],
5
- moduleFileExtensions: ['ts', 'js'],
6
- // Let executed test fixtures import the package by its published subpath
7
- // names, the same way a consumer would (the resource compiler resolves these
8
- // via its own ts.Program; these mappings are for code jest actually runs).
9
- moduleNameMapper: {
10
- '^rastack/tokens$': '<rootDir>/tokens.ts',
11
- },
12
- };
package/test/auth.spec.ts DELETED
@@ -1,207 +0,0 @@
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
- });