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.
- package/CHANGELOG.md +2 -0
- package/cache/cache.ts +258 -0
- package/cache/encrypted.ts +100 -0
- package/cache/index.ts +32 -0
- package/cache/revocation.ts +50 -0
- package/cache/storage.ts +164 -0
- package/cache/types.ts +85 -0
- package/dist/compile/analyze.js +15 -0
- package/dist/compile/index.d.ts +9 -1
- package/dist/compile/index.js +32 -6
- package/dist/compile/model.d.ts +12 -0
- package/dist/compile/openapi.d.ts +2 -1
- package/dist/compile/openapi.js +20 -1
- package/dist/define/index.d.ts +21 -0
- package/package.json +1 -1
- package/provider/provider.tsx +96 -14
- package/provider/types.ts +63 -0
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +17 -0
- package/src/compile/index.ts +31 -6
- package/src/compile/model.ts +13 -0
- package/src/compile/openapi.ts +17 -1
- package/src/define/index.ts +22 -0
- package/test/cache.spec.ts +263 -0
- package/test/compile.spec.ts +38 -1
package/dist/compile/analyze.js
CHANGED
|
@@ -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;
|
package/dist/compile/index.d.ts
CHANGED
|
@@ -8,7 +8,15 @@ export interface CompileResult {
|
|
|
8
8
|
openapi: any;
|
|
9
9
|
files: string[];
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
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[];
|
package/dist/compile/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|
package/dist/compile/model.d.ts
CHANGED
|
@@ -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;
|
package/dist/compile/openapi.js
CHANGED
|
@@ -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;
|
package/dist/define/index.d.ts
CHANGED
|
@@ -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
package/provider/provider.tsx
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
package/src/compile/analyze.ts
CHANGED
|
@@ -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;
|
package/src/compile/index.ts
CHANGED
|
@@ -17,16 +17,41 @@ export interface CompileResult {
|
|
|
17
17
|
files: string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
package/src/compile/model.ts
CHANGED
|
@@ -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 {
|
package/src/compile/openapi.ts
CHANGED
|
@@ -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,
|