rastack 0.0.20 → 0.0.21

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.
@@ -106,6 +106,7 @@ function analyzeResourceCall(call, checker) {
106
106
  search: options.search,
107
107
  ordering: options.ordering,
108
108
  permission: options.permission,
109
+ access: normaliseAccess(options.access),
109
110
  };
110
111
  }
111
112
  /**
@@ -229,6 +230,20 @@ function normaliseSync(sync) {
229
230
  model.clientIds = true;
230
231
  return model;
231
232
  }
233
+ function normaliseAccess(access) {
234
+ if (!access || typeof access !== "object")
235
+ return undefined;
236
+ const model = {};
237
+ if (typeof access.ownerField === "string")
238
+ model.ownerField = access.ownerField;
239
+ if (access.scope === "owner" || access.scope === "shared") {
240
+ model.scope = access.scope;
241
+ }
242
+ if (Array.isArray(access.adminGroups)) {
243
+ model.adminGroups = access.adminGroups.filter((g) => typeof g === "string");
244
+ }
245
+ return Object.keys(model).length ? model : undefined;
246
+ }
232
247
  function normaliseAdmin(admin) {
233
248
  if (admin === false)
234
249
  return false;
@@ -8,7 +8,15 @@ export interface CompileResult {
8
8
  openapi: any;
9
9
  files: string[];
10
10
  }
11
- /** List every `.ts` resource file under a directory (or accept explicit files). */
11
+ /**
12
+ * Every `.ts` resource file under a directory (recursively), or an explicit
13
+ * file. Recursion is what lets resource definitions live **co-located with the
14
+ * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
15
+ * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
16
+ * `.tsx` route components and `.d.ts` files are skipped; only files with a
17
+ * `resource(...)` call contribute to the manifest, so co-located helpers are
18
+ * harmless.
19
+ */
12
20
  export declare function resolveResourceFiles(input: string): string[];
13
21
  /** Analyse resource definitions into the canonical model (no file output). */
14
22
  export declare function analyzeResources(input: string): ResourceModel[];
@@ -55,17 +55,43 @@ Object.defineProperty(exports, "buildManifest", { enumerable: true, get: functio
55
55
  var openapi_2 = require("./openapi");
56
56
  Object.defineProperty(exports, "buildOpenApi", { enumerable: true, get: function () { return openapi_2.buildOpenApi; } });
57
57
  __exportStar(require("./model"), exports);
58
- /** List every `.ts` resource file under a directory (or accept explicit files). */
58
+ /** Directories never worth walking for resource definitions. */
59
+ const IGNORE_DIRS = new Set([
60
+ "node_modules",
61
+ ".git",
62
+ ".expo",
63
+ ".rad",
64
+ "dist",
65
+ "web-build",
66
+ ]);
67
+ /**
68
+ * Every `.ts` resource file under a directory (recursively), or an explicit
69
+ * file. Recursion is what lets resource definitions live **co-located with the
70
+ * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
71
+ * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
72
+ * `.tsx` route components and `.d.ts` files are skipped; only files with a
73
+ * `resource(...)` call contribute to the manifest, so co-located helpers are
74
+ * harmless.
75
+ */
59
76
  function resolveResourceFiles(input) {
60
77
  const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
61
78
  if (stat?.isFile())
62
79
  return [path.resolve(input)];
63
80
  if (stat?.isDirectory()) {
64
- return fs
65
- .readdirSync(input)
66
- .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
67
- .map((f) => path.resolve(input, f))
68
- .sort();
81
+ const files = [];
82
+ const walk = (dir) => {
83
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
84
+ if (entry.isDirectory()) {
85
+ if (!IGNORE_DIRS.has(entry.name))
86
+ walk(path.join(dir, entry.name));
87
+ }
88
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
89
+ files.push(path.resolve(dir, entry.name));
90
+ }
91
+ }
92
+ };
93
+ walk(input);
94
+ return files.sort();
69
95
  }
70
96
  throw new Error(`No such resources path: ${input}`);
71
97
  }
@@ -37,6 +37,17 @@ export interface SyncModel {
37
37
  export interface AdminModel {
38
38
  listDisplay?: string[];
39
39
  }
40
+ /**
41
+ * Row-level access control, enforced by `rad-api-core` on every surface.
42
+ * `ownerField` names the field stamped with the authenticated subject;
43
+ * `scope: "owner"` places the table under a per-identity warehouse prefix
44
+ * (`tenants/{sub}/…`) so object-storage IAM can fence data per user.
45
+ */
46
+ export interface AccessModel {
47
+ ownerField?: string;
48
+ scope?: "owner" | "shared";
49
+ adminGroups?: string[];
50
+ }
40
51
  export interface ResourceModel {
41
52
  app: string;
42
53
  model: string;
@@ -47,6 +58,7 @@ export interface ResourceModel {
47
58
  search?: string[];
48
59
  ordering?: string[];
49
60
  permission?: string;
61
+ access?: AccessModel;
50
62
  }
51
63
  export interface Manifest {
52
64
  version: 1;
@@ -5,7 +5,8 @@ import { ResourceModel } from "./model";
5
5
  * on — `/api/{app}/v1/{model}/` URL segments and the `x-rastack-sync*` vendor
6
6
  * extensions — so those packages are unchanged. Foreign keys additionally carry
7
7
  * a new `x-rastack-relation` extension so downstream tooling (and the admin) can
8
- * follow relations.
8
+ * follow relations, and access-controlled resources carry `x-rastack-access`
9
+ * (owner field + tenant scope) so the client cache can partition per identity.
9
10
  */
10
11
  export declare function buildOpenApi(resources: ResourceModel[]): any;
11
12
  export declare function openApiToJson(doc: any): string;
@@ -8,7 +8,8 @@ exports.openApiToJson = openApiToJson;
8
8
  * on — `/api/{app}/v1/{model}/` URL segments and the `x-rastack-sync*` vendor
9
9
  * extensions — so those packages are unchanged. Foreign keys additionally carry
10
10
  * a new `x-rastack-relation` extension so downstream tooling (and the admin) can
11
- * follow relations.
11
+ * follow relations, and access-controlled resources carry `x-rastack-access`
12
+ * (owner field + tenant scope) so the client cache can partition per identity.
12
13
  */
13
14
  function buildOpenApi(resources) {
14
15
  const paths = {};
@@ -196,6 +197,7 @@ function queryParam(name, type) {
196
197
  * local resource replicates.
197
198
  */
198
199
  function syncStamp(operation, resource, verb) {
200
+ accessStamp(operation, resource);
199
201
  const sync = resource.sync;
200
202
  if (!sync || sync.mode !== "local")
201
203
  return operation;
@@ -208,6 +210,23 @@ function syncStamp(operation, resource, verb) {
208
210
  operation["x-rastack-sync-client-ids"] = true;
209
211
  return operation;
210
212
  }
213
+ /**
214
+ * Stamp `x-rastack-access` on an operation of an access-controlled resource so
215
+ * downstream tooling (hooks, the local cache) knows the owner field and whether
216
+ * the table is tenant-partitioned on object storage.
217
+ */
218
+ function accessStamp(operation, resource) {
219
+ const access = resource.access;
220
+ if (!access)
221
+ return;
222
+ const stamp = {};
223
+ if (access.ownerField)
224
+ stamp.ownerField = access.ownerField;
225
+ if (access.scope)
226
+ stamp.scope = access.scope;
227
+ if (Object.keys(stamp).length)
228
+ operation["x-rastack-access"] = stamp;
229
+ }
211
230
  function isConfirmed(confirmed, verb, operation) {
212
231
  if (!confirmed || !confirmed.length)
213
232
  return false;
@@ -86,6 +86,25 @@ export interface SyncOptions {
86
86
  load?: "instant" | "lazy";
87
87
  clientIds?: boolean;
88
88
  }
89
+ /**
90
+ * Row-level access control. Enforced by `rad-api-core`, so the rules are
91
+ * identical on the server (Lambda / `rad serve`) and in the in-browser WASM
92
+ * engine.
93
+ *
94
+ * - `ownerField` — the field that records the owning identity (the verified
95
+ * token `sub`). Rows are only listed/read/written by their owner; the field
96
+ * is stamped from the token on create and immutable on update.
97
+ * - `scope: "owner"` — additionally *physically* partitions the resource's
98
+ * Iceberg table per identity (`tenants/{sub}/{app}.db/{model}/…`), so
99
+ * object-storage credentials (e.g. Cognito Identity Pool temp creds) can be
100
+ * IAM-restricted to `tenants/${"${cognito-identity.amazonaws.com:sub}"}/*`.
101
+ * - `adminGroups` — token groups (`cognito:groups`) that bypass row filters.
102
+ */
103
+ export interface AccessOptions {
104
+ ownerField?: string;
105
+ scope?: "owner" | "shared";
106
+ adminGroups?: string[];
107
+ }
89
108
  export interface ResourceOptions {
90
109
  /** DRF-style search fields. */
91
110
  search?: string[];
@@ -99,6 +118,8 @@ export interface ResourceOptions {
99
118
  sync?: SyncOptions;
100
119
  /** Access policy (secure by default). */
101
120
  permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
121
+ /** Row-level security + physical tenant partitioning. */
122
+ access?: AccessOptions;
102
123
  }
103
124
  /**
104
125
  * A resource definition. `App`/`Model` are preserved as string-literal type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -23,6 +23,15 @@ import {
23
23
  seedFromIceberg,
24
24
  WarehousePersistence,
25
25
  } from "./warehouse";
26
+ import {
27
+ CacheStorage,
28
+ defaultCacheStorage,
29
+ deleteIndexedDb,
30
+ httpFileSource,
31
+ IcebergFileCache,
32
+ isAccessRevoked,
33
+ purgeIdentityCache,
34
+ } from "../cache";
26
35
 
27
36
  const RadContext = createContext<RadClient | null>(null);
28
37
 
@@ -45,12 +54,38 @@ export function RAStackProvider({
45
54
  const [status, setStatus] = useState<RadStatus>("idle");
46
55
  const engineRef = useRef<RadEngine | null>(null);
47
56
  const persistRef = useRef<(() => void) & { flush: () => void }>();
57
+ const cacheStorageRef = useRef<CacheStorage | null>(null);
48
58
 
49
59
  const setStatusSafe = (next: RadStatus) => {
50
60
  setStatus(next);
51
61
  config.onStatusChange?.(next);
52
62
  };
53
63
 
64
+ // Locally persisted state is namespaced per identity, so two accounts on
65
+ // one device never share bytes.
66
+ const warehouseDb = withIdentity(
67
+ config.databaseName || "rad-warehouse",
68
+ config.identity,
69
+ );
70
+
71
+ /** Delete everything this identity persisted locally. */
72
+ const purgeLocal = async () => {
73
+ if (cacheStorageRef.current) {
74
+ await purgeIdentityCache(cacheStorageRef.current, config.identity);
75
+ }
76
+ await deleteIndexedDb(warehouseDb);
77
+ // Replicated rows in the sync engine's store are derived from the same
78
+ // access — they go too.
79
+ await deleteIndexedDb(config.syncDatabaseName || "rad-sync");
80
+ };
81
+
82
+ /** Revocation entry point shared by automatic detection and app signals. */
83
+ const handleAccessRevoked = async () => {
84
+ await purgeLocal();
85
+ config.onAccessRevoked?.();
86
+ setStatusSafe("revoked");
87
+ };
88
+
54
89
  useEffect(() => {
55
90
  let cancelled = false;
56
91
  let ejectToken: number | undefined;
@@ -84,19 +119,51 @@ export function RAStackProvider({
84
119
  if (cancelled) return;
85
120
  const engine = createEngine(mod, config.manifest, config.openapi);
86
121
 
122
+ // Run the in-browser engine as the signed-in identity so owner stamping
123
+ // and row-level security match the server exactly.
124
+ if (config.identity) {
125
+ engine.setAuth?.(config.identity, config.groups ?? []);
126
+ }
127
+
87
128
  // Warehouse persistence: IndexedDB for `local`, an S3 sink for `s3`.
88
- const persistence = pickPersistence(config);
129
+ const persistence = pickPersistence(config, warehouseDb);
89
130
 
90
131
  setStatusSafe("seeding");
91
- // A persisted local copy (with the user's edits) wins; otherwise seed
92
- // from the committed Iceberg warehouse — a directory of files walked over
93
- // HTTP, not a blob.
94
- const existing = await persistence.load();
95
- if (existing) {
96
- await engine.importWarehouse(existing);
97
- } else {
98
- const base = config.warehouseBaseUrl ?? "/data/warehouse/";
99
- await seedFromIceberg(engine, base, manifestResources(config.manifest));
132
+ try {
133
+ // A persisted local copy (with the user's edits) wins; otherwise seed
134
+ // from the warehouse's own Iceberg files — through the local file
135
+ // cache by default, so a warm client revalidates one small pointer
136
+ // per table and reads everything else from the device.
137
+ const existing = await persistence.load();
138
+ if (existing) {
139
+ await engine.importWarehouse(existing);
140
+ } else {
141
+ const base = config.warehouseBaseUrl ?? "/data/warehouse/";
142
+ const resources = manifestResources(config.manifest);
143
+ if (config.cache === false) {
144
+ await seedFromIceberg(engine, base, resources);
145
+ } else {
146
+ const storage =
147
+ typeof config.cache === "object"
148
+ ? config.cache
149
+ : defaultCacheStorage();
150
+ cacheStorageRef.current = storage;
151
+ const cache = new IcebergFileCache({
152
+ storage,
153
+ source: config.fileSource ?? httpFileSource(base),
154
+ identity: config.identity,
155
+ maxOfflineMs: config.maxOfflineMs,
156
+ });
157
+ await cache.seed(engine, resources);
158
+ }
159
+ }
160
+ } catch (error) {
161
+ if (isAccessRevoked(error)) {
162
+ // Access is gone ⇒ so is every locally persisted byte it fetched.
163
+ await handleAccessRevoked();
164
+ return;
165
+ }
166
+ throw error;
100
167
  }
101
168
  if (cancelled) return;
102
169
 
@@ -127,7 +194,7 @@ export function RAStackProvider({
127
194
  engineRef.current = null;
128
195
  };
129
196
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, [config.mode, config.baseURL, config.warehouseBaseUrl]);
197
+ }, [config.mode, config.baseURL, config.warehouseBaseUrl, config.identity]);
131
198
 
132
199
  const client = useMemo<RadClient>(
133
200
  () => ({
@@ -149,14 +216,24 @@ export function RAStackProvider({
149
216
  async persistNow() {
150
217
  persistRef.current?.flush();
151
218
  },
219
+ async purgeLocal() {
220
+ await purgeLocal();
221
+ },
222
+ async reportAccessRevoked() {
223
+ await handleAccessRevoked();
224
+ },
152
225
  }),
153
- [config.mode, status],
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
227
+ [config.mode, status, config.identity],
154
228
  );
155
229
 
156
230
  return <RadContext.Provider value={client}>{children}</RadContext.Provider>;
157
231
  }
158
232
 
159
- function pickPersistence(config: RAStackProviderConfig): WarehouseSource {
233
+ function pickPersistence(
234
+ config: RAStackProviderConfig,
235
+ databaseName: string,
236
+ ): WarehouseSource {
160
237
  if (config.mode === "s3") {
161
238
  if (!config.warehouse) {
162
239
  throw new Error(
@@ -168,10 +245,15 @@ function pickPersistence(config: RAStackProviderConfig): WarehouseSource {
168
245
  if (config.persist === false) {
169
246
  return { load: async () => null, save: async () => {} };
170
247
  }
171
- const store = new WarehousePersistence(config.databaseName || "rad-warehouse");
248
+ const store = new WarehousePersistence(databaseName);
172
249
  return { load: () => store.load(), save: (blob) => store.save(blob) };
173
250
  }
174
251
 
252
+ /** Suffix a local database/cache name with the identity that owns it. */
253
+ function withIdentity(name: string, identity?: string): string {
254
+ return identity ? `${name}::${identity}` : name;
255
+ }
256
+
175
257
  /** Access the imperative Rad client (engine, status, warehouse helpers). */
176
258
  export function useRadClient(): RadClient {
177
259
  const client = useContext(RadContext);
package/provider/types.ts CHANGED
@@ -26,6 +26,12 @@ export interface RadEngine {
26
26
  seedFile(key: string, bytes: Uint8Array): Promise<void>;
27
27
  importWarehouse(blob: Uint8Array): Promise<void>;
28
28
  exportWarehouse(): Promise<Uint8Array>;
29
+ /**
30
+ * Run requests as a signed-in identity (Cognito `sub` + `cognito:groups`),
31
+ * so owner stamping / row-level security in the in-browser engine match the
32
+ * server. Optional for older engine bundles.
33
+ */
34
+ setAuth?(subject: string | undefined, groups?: string[]): void;
29
35
  }
30
36
 
31
37
  /** Constructor of the generated `RadApi` class. */
@@ -61,6 +67,35 @@ export interface RAStackProviderConfig {
61
67
  /** Attach a Bearer token to every request. */
62
68
  getToken?: () => string | Promise<string>;
63
69
 
70
+ // -- identity & local-data security (all modes) --
71
+ /**
72
+ * The signed-in identity — the verified Cognito `sub`. It namespaces every
73
+ * locally persisted byte (Iceberg file cache, warehouse snapshot DB), so
74
+ * accounts sharing a device can't read each other's data, and it is the
75
+ * subject the WASM engine stamps/filters owner rows with.
76
+ */
77
+ identity?: string;
78
+ /** Verified group claims (`cognito:groups`) for the WASM engine's auth. */
79
+ groups?: string[];
80
+ /**
81
+ * Fired when the data source reports this identity's access has been
82
+ * revoked (401/403/AccessDenied). By then the provider has already deleted
83
+ * the identity's local data; use this to route to sign-in.
84
+ */
85
+ onAccessRevoked?: () => void;
86
+ /**
87
+ * The sync engine's IndexedDB database (`rad-sync` by default) so
88
+ * revocation and `purgeLocal()` erase replicated rows too. Set to the same
89
+ * `databaseName` you pass the sync engine if you changed it.
90
+ */
91
+ syncDatabaseName?: string;
92
+ /**
93
+ * Offline-retention bound for the Iceberg file cache: cached data older
94
+ * than this without a successful access re-verification is deleted rather
95
+ * than served (`CacheExpiredError`). Unset = no bound.
96
+ */
97
+ maxOfflineMs?: number;
98
+
64
99
  // -- local / s3 mode --
65
100
  /** `schema.rad.json` — object or JSON string. Required for WASM modes. */
66
101
  manifest?: object | string;
@@ -78,6 +113,21 @@ export interface RAStackProviderConfig {
78
113
  persist?: boolean;
79
114
  /** IndexedDB database name for the local warehouse. */
80
115
  databaseName?: string;
116
+ /**
117
+ * Cache the warehouse's Iceberg files locally (IndexedDB on web; supply a
118
+ * `CacheStorage` for React Native) so repeat loads revalidate one tiny
119
+ * pointer per table and read everything else from the device. `false`
120
+ * disables caching; default on. Immutable Parquet/metadata files are cached
121
+ * forever by key — invalidation is driven by Iceberg's own snapshot pointer.
122
+ */
123
+ cache?: boolean | import("../cache").CacheStorage;
124
+ /**
125
+ * Where warehouse files are fetched from — defaults to HTTP GETs against
126
+ * `warehouseBaseUrl`. Supply a SigV4-signing source (AWS SDK S3 client over
127
+ * Cognito Identity Pool credentials) for `s3` mode. Must throw
128
+ * `AccessRevokedError` on 401/403 so revocation deletes local data.
129
+ */
130
+ fileSource?: import("../cache").WarehouseFileSource;
81
131
  /** `s3` mode: how to load/save the warehouse blob from object storage. */
82
132
  warehouse?: WarehouseSource;
83
133
  /**
@@ -97,6 +147,7 @@ export type RadStatus =
97
147
  | "loading" // fetching/instantiating the WASM engine
98
148
  | "seeding" // importing the warehouse
99
149
  | "ready"
150
+ | "revoked" // access revoked — local data has been deleted
100
151
  | "error";
101
152
 
102
153
  /** Imperative handle exposed via `useRadClient()`. */
@@ -111,4 +162,16 @@ export interface RadClient {
111
162
  downloadWarehouse(filename?: string): Promise<void>;
112
163
  /** Force-persist now (IndexedDB and/or the `s3` sink). */
113
164
  persistNow(): Promise<void>;
165
+ /**
166
+ * Delete everything this identity has persisted on the device — the Iceberg
167
+ * file cache, the warehouse snapshot DB, and the sync engine's DB. Call on
168
+ * sign-out; called automatically when the source reports access revoked.
169
+ */
170
+ purgeLocal(): Promise<void>;
171
+ /**
172
+ * Tell the provider access has been revoked (e.g. a Cognito refresh failed
173
+ * definitively in `remote` mode, where no seeding fetch would notice):
174
+ * purges local data, fires `onAccessRevoked`, sets status `revoked`.
175
+ */
176
+ reportAccessRevoked(): Promise<void>;
114
177
  }
package/runtime.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./hooks";
2
2
  export * from "./types";
3
3
  export * from "./provider";
4
+ export * from "./cache";
@@ -1,5 +1,6 @@
1
1
  import * as ts from "typescript";
2
2
  import {
3
+ AccessModel,
3
4
  AdminModel,
4
5
  FieldModel,
5
6
  RelationModel,
@@ -93,6 +94,7 @@ function analyzeResourceCall(
93
94
  search: options.search,
94
95
  ordering: options.ordering,
95
96
  permission: options.permission,
97
+ access: normaliseAccess(options.access),
96
98
  };
97
99
  }
98
100
 
@@ -215,6 +217,21 @@ function normaliseSync(sync: any): SyncModel | undefined {
215
217
  return model;
216
218
  }
217
219
 
220
+ function normaliseAccess(access: any): AccessModel | undefined {
221
+ if (!access || typeof access !== "object") return undefined;
222
+ const model: AccessModel = {};
223
+ if (typeof access.ownerField === "string") model.ownerField = access.ownerField;
224
+ if (access.scope === "owner" || access.scope === "shared") {
225
+ model.scope = access.scope;
226
+ }
227
+ if (Array.isArray(access.adminGroups)) {
228
+ model.adminGroups = access.adminGroups.filter(
229
+ (g: unknown) => typeof g === "string",
230
+ );
231
+ }
232
+ return Object.keys(model).length ? model : undefined;
233
+ }
234
+
218
235
  function normaliseAdmin(admin: any): AdminModel | false | undefined {
219
236
  if (admin === false) return false;
220
237
  if (!admin || typeof admin !== "object") return undefined;
@@ -17,16 +17,41 @@ export interface CompileResult {
17
17
  files: string[];
18
18
  }
19
19
 
20
- /** List every `.ts` resource file under a directory (or accept explicit files). */
20
+ /** Directories never worth walking for resource definitions. */
21
+ const IGNORE_DIRS = new Set([
22
+ "node_modules",
23
+ ".git",
24
+ ".expo",
25
+ ".rad",
26
+ "dist",
27
+ "web-build",
28
+ ]);
29
+
30
+ /**
31
+ * Every `.ts` resource file under a directory (recursively), or an explicit
32
+ * file. Recursion is what lets resource definitions live **co-located with the
33
+ * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
34
+ * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
35
+ * `.tsx` route components and `.d.ts` files are skipped; only files with a
36
+ * `resource(...)` call contribute to the manifest, so co-located helpers are
37
+ * harmless.
38
+ */
21
39
  export function resolveResourceFiles(input: string): string[] {
22
40
  const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
23
41
  if (stat?.isFile()) return [path.resolve(input)];
24
42
  if (stat?.isDirectory()) {
25
- return fs
26
- .readdirSync(input)
27
- .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
28
- .map((f) => path.resolve(input, f))
29
- .sort();
43
+ const files: string[] = [];
44
+ const walk = (dir: string): void => {
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ if (entry.isDirectory()) {
47
+ if (!IGNORE_DIRS.has(entry.name)) walk(path.join(dir, entry.name));
48
+ } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
49
+ files.push(path.resolve(dir, entry.name));
50
+ }
51
+ }
52
+ };
53
+ walk(input);
54
+ return files.sort();
30
55
  }
31
56
  throw new Error(`No such resources path: ${input}`);
32
57
  }
@@ -51,6 +51,18 @@ export interface AdminModel {
51
51
  listDisplay?: string[];
52
52
  }
53
53
 
54
+ /**
55
+ * Row-level access control, enforced by `rad-api-core` on every surface.
56
+ * `ownerField` names the field stamped with the authenticated subject;
57
+ * `scope: "owner"` places the table under a per-identity warehouse prefix
58
+ * (`tenants/{sub}/…`) so object-storage IAM can fence data per user.
59
+ */
60
+ export interface AccessModel {
61
+ ownerField?: string;
62
+ scope?: "owner" | "shared";
63
+ adminGroups?: string[];
64
+ }
65
+
54
66
  export interface ResourceModel {
55
67
  app: string;
56
68
  model: string;
@@ -61,6 +73,7 @@ export interface ResourceModel {
61
73
  search?: string[];
62
74
  ordering?: string[];
63
75
  permission?: string;
76
+ access?: AccessModel;
64
77
  }
65
78
 
66
79
  export interface Manifest {
@@ -6,7 +6,8 @@ import { FieldModel, ResourceModel } from "./model";
6
6
  * on — `/api/{app}/v1/{model}/` URL segments and the `x-rastack-sync*` vendor
7
7
  * extensions — so those packages are unchanged. Foreign keys additionally carry
8
8
  * a new `x-rastack-relation` extension so downstream tooling (and the admin) can
9
- * follow relations.
9
+ * follow relations, and access-controlled resources carry `x-rastack-access`
10
+ * (owner field + tenant scope) so the client cache can partition per identity.
10
11
  */
11
12
  export function buildOpenApi(resources: ResourceModel[]): any {
12
13
  const paths: Record<string, any> = {};
@@ -231,6 +232,7 @@ function queryParam(name: string, type: string): any {
231
232
  * local resource replicates.
232
233
  */
233
234
  function syncStamp(operation: any, resource: ResourceModel, verb: string): any {
235
+ accessStamp(operation, resource);
234
236
  const sync = resource.sync;
235
237
  if (!sync || sync.mode !== "local") return operation;
236
238
 
@@ -242,6 +244,20 @@ function syncStamp(operation: any, resource: ResourceModel, verb: string): any {
242
244
  return operation;
243
245
  }
244
246
 
247
+ /**
248
+ * Stamp `x-rastack-access` on an operation of an access-controlled resource so
249
+ * downstream tooling (hooks, the local cache) knows the owner field and whether
250
+ * the table is tenant-partitioned on object storage.
251
+ */
252
+ function accessStamp(operation: any, resource: ResourceModel): void {
253
+ const access = resource.access;
254
+ if (!access) return;
255
+ const stamp: any = {};
256
+ if (access.ownerField) stamp.ownerField = access.ownerField;
257
+ if (access.scope) stamp.scope = access.scope;
258
+ if (Object.keys(stamp).length) operation["x-rastack-access"] = stamp;
259
+ }
260
+
245
261
  function isConfirmed(
246
262
  confirmed: string[] | undefined,
247
263
  verb: string,