rastack 0.0.20 → 0.0.22
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 +4 -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/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,
|
package/src/define/index.ts
CHANGED
|
@@ -112,6 +112,26 @@ export interface SyncOptions {
|
|
|
112
112
|
clientIds?: boolean;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Row-level access control. Enforced by `rad-api-core`, so the rules are
|
|
117
|
+
* identical on the server (Lambda / `rad serve`) and in the in-browser WASM
|
|
118
|
+
* engine.
|
|
119
|
+
*
|
|
120
|
+
* - `ownerField` — the field that records the owning identity (the verified
|
|
121
|
+
* token `sub`). Rows are only listed/read/written by their owner; the field
|
|
122
|
+
* is stamped from the token on create and immutable on update.
|
|
123
|
+
* - `scope: "owner"` — additionally *physically* partitions the resource's
|
|
124
|
+
* Iceberg table per identity (`tenants/{sub}/{app}.db/{model}/…`), so
|
|
125
|
+
* object-storage credentials (e.g. Cognito Identity Pool temp creds) can be
|
|
126
|
+
* IAM-restricted to `tenants/${"${cognito-identity.amazonaws.com:sub}"}/*`.
|
|
127
|
+
* - `adminGroups` — token groups (`cognito:groups`) that bypass row filters.
|
|
128
|
+
*/
|
|
129
|
+
export interface AccessOptions {
|
|
130
|
+
ownerField?: string;
|
|
131
|
+
scope?: "owner" | "shared";
|
|
132
|
+
adminGroups?: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
export interface ResourceOptions {
|
|
116
136
|
/** DRF-style search fields. */
|
|
117
137
|
search?: string[];
|
|
@@ -123,6 +143,8 @@ export interface ResourceOptions {
|
|
|
123
143
|
sync?: SyncOptions;
|
|
124
144
|
/** Access policy (secure by default). */
|
|
125
145
|
permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
|
|
146
|
+
/** Row-level security + physical tenant partitioning. */
|
|
147
|
+
access?: AccessOptions;
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
/**
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccessRevokedError,
|
|
3
|
+
CacheExpiredError,
|
|
4
|
+
createEncryptedCacheStorage,
|
|
5
|
+
generateEncryptionKey,
|
|
6
|
+
IcebergFileCache,
|
|
7
|
+
MemoryCacheStorage,
|
|
8
|
+
purgeLocalData,
|
|
9
|
+
WarehouseFileSource,
|
|
10
|
+
} from "../cache";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The cache is exercised against an in-memory "object store" that counts
|
|
14
|
+
* fetches — the observable contract is Iceberg's: the version hint is the only
|
|
15
|
+
* file re-fetched once cached copies exist, immutable snapshot/data files are
|
|
16
|
+
* fetched exactly once, and revoked access deletes the identity's cache.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
|
|
21
|
+
class FakeWarehouse implements WarehouseFileSource {
|
|
22
|
+
files = new Map<string, Uint8Array>();
|
|
23
|
+
fetches: string[] = [];
|
|
24
|
+
revoked = false;
|
|
25
|
+
offline = false;
|
|
26
|
+
|
|
27
|
+
async fetchFile(key: string): Promise<Uint8Array | null> {
|
|
28
|
+
if (this.revoked) throw new AccessRevokedError("revoked", 403);
|
|
29
|
+
if (this.offline) throw new Error("network unreachable");
|
|
30
|
+
this.fetches.push(key);
|
|
31
|
+
return this.files.get(key) ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Commit snapshot `version` for the table with the given data files. */
|
|
35
|
+
commit(prefix: string, version: number, dataFiles: Record<string, object[]>) {
|
|
36
|
+
this.files.set(
|
|
37
|
+
`${prefix}/metadata/version-hint.text`,
|
|
38
|
+
encoder.encode(String(version)),
|
|
39
|
+
);
|
|
40
|
+
this.files.set(
|
|
41
|
+
`${prefix}/metadata/v${version}.metadata.json`,
|
|
42
|
+
encoder.encode(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
format: "rad-iceberg-v1",
|
|
45
|
+
data_files: Object.keys(dataFiles),
|
|
46
|
+
next_int_id: 99,
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
for (const [rel, rows] of Object.entries(dataFiles)) {
|
|
51
|
+
this.files.set(`${prefix}/${rel}`, encoder.encode(JSON.stringify(rows)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class FakeEngine {
|
|
57
|
+
seeded: string[] = [];
|
|
58
|
+
async seedFile(key: string): Promise<void> {
|
|
59
|
+
this.seeded.push(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const RESOURCES = [{ app: "airports", model: "airport" }];
|
|
64
|
+
const PREFIX = "airports.db/airport";
|
|
65
|
+
|
|
66
|
+
function makeCache(source: FakeWarehouse, identity = "alice-sub") {
|
|
67
|
+
const storage = new MemoryCacheStorage();
|
|
68
|
+
return {
|
|
69
|
+
storage,
|
|
70
|
+
cache: new IcebergFileCache({ storage, source, identity }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("IcebergFileCache", () => {
|
|
75
|
+
it("cold seed fetches the Iceberg tree and feeds it to the engine", async () => {
|
|
76
|
+
const source = new FakeWarehouse();
|
|
77
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1, code: "JFK" }] });
|
|
78
|
+
const { cache } = makeCache(source);
|
|
79
|
+
const engine = new FakeEngine();
|
|
80
|
+
|
|
81
|
+
const stats = await cache.seed(engine, RESOURCES);
|
|
82
|
+
|
|
83
|
+
expect(stats).toEqual({ tables: 1, fromCache: 0, fetched: 3 });
|
|
84
|
+
expect(engine.seeded).toEqual([
|
|
85
|
+
`${PREFIX}/metadata/version-hint.text`,
|
|
86
|
+
`${PREFIX}/metadata/v1.metadata.json`,
|
|
87
|
+
`${PREFIX}/data/v1.json`,
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("warm seed with no new commit refetches only the version hint", async () => {
|
|
92
|
+
const source = new FakeWarehouse();
|
|
93
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
|
|
94
|
+
const { cache } = makeCache(source);
|
|
95
|
+
|
|
96
|
+
await cache.seed(new FakeEngine(), RESOURCES);
|
|
97
|
+
source.fetches = [];
|
|
98
|
+
|
|
99
|
+
const stats = await cache.seed(new FakeEngine(), RESOURCES);
|
|
100
|
+
// One small network read (the pointer); the immutable files came from cache.
|
|
101
|
+
expect(source.fetches).toEqual([`${PREFIX}/metadata/version-hint.text`]);
|
|
102
|
+
expect(stats).toEqual({ tables: 1, fromCache: 2, fetched: 1 });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("a new snapshot fetches only unseen files and prunes superseded ones", async () => {
|
|
106
|
+
const source = new FakeWarehouse();
|
|
107
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
|
|
108
|
+
const { cache, storage } = makeCache(source);
|
|
109
|
+
await cache.seed(new FakeEngine(), RESOURCES);
|
|
110
|
+
|
|
111
|
+
// Update/delete rewrite a compacted snapshot: v2 supersedes v1's file.
|
|
112
|
+
source.commit(PREFIX, 2, { "data/v2.json": [{ id: 1, name: "x" }] });
|
|
113
|
+
source.fetches = [];
|
|
114
|
+
|
|
115
|
+
const engine = new FakeEngine();
|
|
116
|
+
await cache.seed(engine, RESOURCES);
|
|
117
|
+
|
|
118
|
+
expect(source.fetches).toEqual([
|
|
119
|
+
`${PREFIX}/metadata/version-hint.text`,
|
|
120
|
+
`${PREFIX}/metadata/v2.metadata.json`,
|
|
121
|
+
`${PREFIX}/data/v2.json`,
|
|
122
|
+
]);
|
|
123
|
+
expect(engine.seeded).toContain(`${PREFIX}/data/v2.json`);
|
|
124
|
+
// The superseded snapshot's files no longer occupy the device.
|
|
125
|
+
const keys = await storage.keys("");
|
|
126
|
+
expect(keys.some((k) => k.includes("v1.metadata.json"))).toBe(false);
|
|
127
|
+
expect(keys.some((k) => k.includes("data/v1.json"))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("falls back to the cached pointer when offline", async () => {
|
|
131
|
+
const source = new FakeWarehouse();
|
|
132
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
|
|
133
|
+
const { cache } = makeCache(source);
|
|
134
|
+
await cache.seed(new FakeEngine(), RESOURCES);
|
|
135
|
+
|
|
136
|
+
source.offline = true;
|
|
137
|
+
const engine = new FakeEngine();
|
|
138
|
+
const stats = await cache.seed(engine, RESOURCES);
|
|
139
|
+
expect(stats.tables).toBe(1);
|
|
140
|
+
expect(stats.fetched).toBe(0);
|
|
141
|
+
expect(engine.seeded).toHaveLength(3);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("revoked access deletes the identity's cached data and surfaces the error", async () => {
|
|
145
|
+
const source = new FakeWarehouse();
|
|
146
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
|
|
147
|
+
const { cache, storage } = makeCache(source);
|
|
148
|
+
await cache.seed(new FakeEngine(), RESOURCES);
|
|
149
|
+
expect(await storage.keys("alice-sub::")).not.toHaveLength(0);
|
|
150
|
+
|
|
151
|
+
source.revoked = true;
|
|
152
|
+
await expect(cache.seed(new FakeEngine(), RESOURCES)).rejects.toThrow(
|
|
153
|
+
AccessRevokedError,
|
|
154
|
+
);
|
|
155
|
+
// Nothing of alice's survives on the device.
|
|
156
|
+
expect(await storage.keys("alice-sub::")).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("enforces the offline-retention bound: stale cache is deleted, not served", async () => {
|
|
160
|
+
const source = new FakeWarehouse();
|
|
161
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
|
|
162
|
+
const storage = new MemoryCacheStorage();
|
|
163
|
+
let clock = 1_000_000;
|
|
164
|
+
const cache = new IcebergFileCache({
|
|
165
|
+
storage,
|
|
166
|
+
source,
|
|
167
|
+
identity: "alice",
|
|
168
|
+
maxOfflineMs: 60_000,
|
|
169
|
+
now: () => clock,
|
|
170
|
+
});
|
|
171
|
+
await cache.seed(new FakeEngine(), RESOURCES);
|
|
172
|
+
|
|
173
|
+
// Offline but within the bound: cached data still serves.
|
|
174
|
+
source.offline = true;
|
|
175
|
+
clock += 30_000;
|
|
176
|
+
expect((await cache.seed(new FakeEngine(), RESOURCES)).tables).toBe(1);
|
|
177
|
+
|
|
178
|
+
// Past the bound: the cache refuses AND deletes the identity's data.
|
|
179
|
+
clock += 60_001;
|
|
180
|
+
await expect(cache.seed(new FakeEngine(), RESOURCES)).rejects.toThrow(
|
|
181
|
+
CacheExpiredError,
|
|
182
|
+
);
|
|
183
|
+
expect(await storage.keys("alice::")).toHaveLength(0);
|
|
184
|
+
|
|
185
|
+
// Back online: re-verification restores service from the source.
|
|
186
|
+
source.offline = false;
|
|
187
|
+
expect((await cache.seed(new FakeEngine(), RESOURCES)).tables).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("namespaces isolate identities sharing one device", async () => {
|
|
191
|
+
const source = new FakeWarehouse();
|
|
192
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1 }] });
|
|
193
|
+
const storage = new MemoryCacheStorage();
|
|
194
|
+
const alice = new IcebergFileCache({ storage, source, identity: "alice" });
|
|
195
|
+
const bob = new IcebergFileCache({ storage, source, identity: "bob" });
|
|
196
|
+
|
|
197
|
+
await alice.seed(new FakeEngine(), RESOURCES);
|
|
198
|
+
await bob.seed(new FakeEngine(), RESOURCES);
|
|
199
|
+
|
|
200
|
+
await purgeLocalData({ identity: "alice", cacheStorage: storage });
|
|
201
|
+
expect(await storage.keys("alice::")).toHaveLength(0);
|
|
202
|
+
expect(await storage.keys("bob::")).not.toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("createEncryptedCacheStorage", () => {
|
|
207
|
+
it("round-trips values while storing only ciphertext", async () => {
|
|
208
|
+
const inner = new MemoryCacheStorage();
|
|
209
|
+
const key = await generateEncryptionKey();
|
|
210
|
+
const storage = createEncryptedCacheStorage(inner, key);
|
|
211
|
+
const plaintext = encoder.encode("sensitive row data");
|
|
212
|
+
|
|
213
|
+
await storage.put("k", plaintext);
|
|
214
|
+
expect(new Uint8Array((await storage.get("k"))!)).toEqual(plaintext);
|
|
215
|
+
|
|
216
|
+
// What actually hits the device is sealed: different bytes, and a fresh
|
|
217
|
+
// random IV means two writes of the same value differ too.
|
|
218
|
+
const sealed1 = (await inner.get("k"))!;
|
|
219
|
+
expect(Buffer.from(sealed1).includes(Buffer.from(plaintext))).toBe(false);
|
|
220
|
+
await storage.put("k", plaintext);
|
|
221
|
+
expect((await inner.get("k"))!).not.toEqual(sealed1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("treats undecryptable bytes as a cache miss, never as data", async () => {
|
|
225
|
+
const inner = new MemoryCacheStorage();
|
|
226
|
+
const storage = createEncryptedCacheStorage(inner, await generateEncryptionKey());
|
|
227
|
+
await storage.put("k", encoder.encode("secret"));
|
|
228
|
+
|
|
229
|
+
// Read back under a different key → miss.
|
|
230
|
+
const other = createEncryptedCacheStorage(inner, await generateEncryptionKey());
|
|
231
|
+
expect(await other.get("k")).toBeNull();
|
|
232
|
+
|
|
233
|
+
// Tampered ciphertext → authenticated decryption fails → miss.
|
|
234
|
+
const sealed = (await inner.get("k"))!;
|
|
235
|
+
sealed[sealed.length - 1] ^= 0xff;
|
|
236
|
+
await inner.put("k", sealed);
|
|
237
|
+
expect(await storage.get("k")).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("works end-to-end as the IcebergFileCache storage", async () => {
|
|
241
|
+
const source = new FakeWarehouse();
|
|
242
|
+
source.commit(PREFIX, 1, { "data/v1.json": [{ id: 1, code: "JFK" }] });
|
|
243
|
+
const inner = new MemoryCacheStorage();
|
|
244
|
+
const cache = new IcebergFileCache({
|
|
245
|
+
storage: createEncryptedCacheStorage(inner, await generateEncryptionKey()),
|
|
246
|
+
source,
|
|
247
|
+
identity: "alice",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await cache.seed(new FakeEngine(), RESOURCES);
|
|
251
|
+
source.fetches = [];
|
|
252
|
+
const stats = await cache.seed(new FakeEngine(), RESOURCES);
|
|
253
|
+
// Warm-cache behaviour is unchanged by encryption.
|
|
254
|
+
expect(stats).toEqual({ tables: 1, fromCache: 2, fetched: 1 });
|
|
255
|
+
// And nothing readable sits on the device.
|
|
256
|
+
const stored = await Promise.all(
|
|
257
|
+
(await inner.keys("alice::")).map((k) => inner.get(k)),
|
|
258
|
+
);
|
|
259
|
+
for (const bytes of stored) {
|
|
260
|
+
expect(Buffer.from(bytes!).includes(Buffer.from("JFK"))).toBe(false);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
package/test/compile.spec.ts
CHANGED
|
@@ -45,6 +45,14 @@ export const Gate = resource("airports", "gate", {
|
|
|
45
45
|
export const Booking = resource("airports", "booking", {
|
|
46
46
|
reference: s.string({ maxLength: 12 }),
|
|
47
47
|
}, { sync: { mode: "local", clientIds: true } });
|
|
48
|
+
|
|
49
|
+
export const Itinerary = resource("airports", "itinerary", {
|
|
50
|
+
owner: s.string({ maxLength: 64 }),
|
|
51
|
+
notes: s.string({ maxLength: 500 }),
|
|
52
|
+
}, {
|
|
53
|
+
permission: "authenticated",
|
|
54
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
55
|
+
});
|
|
48
56
|
`;
|
|
49
57
|
|
|
50
58
|
function findResource(
|
|
@@ -112,6 +120,21 @@ describe("rad compile — resource analysis & FK inference", () => {
|
|
|
112
120
|
});
|
|
113
121
|
});
|
|
114
122
|
|
|
123
|
+
it("captures row-level access config (ownerField / scope / adminGroups)", () => {
|
|
124
|
+
const resources = analyzeResources(resourcesDir);
|
|
125
|
+
const itinerary = findResource(resources, "airports", "itinerary");
|
|
126
|
+
|
|
127
|
+
expect(itinerary.permission).toBe("authenticated");
|
|
128
|
+
expect(itinerary.access).toEqual({
|
|
129
|
+
ownerField: "owner",
|
|
130
|
+
scope: "owner",
|
|
131
|
+
adminGroups: ["staff"],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Resources without an access block stay unstamped.
|
|
135
|
+
expect(findResource(resources, "airports", "airport").access).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
115
138
|
it("reports no collisions for a valid graph", () => {
|
|
116
139
|
const resources = analyzeResources(resourcesDir);
|
|
117
140
|
expect(collisions(resources)).toEqual([]);
|
|
@@ -132,7 +155,7 @@ describe("rad compile — OpenAPI emission", () => {
|
|
|
132
155
|
const result = compile(resourcesDir, outDir);
|
|
133
156
|
expect(existsSync(join(outDir, "schema.rad.json"))).toBe(true);
|
|
134
157
|
expect(existsSync(join(outDir, "openapi.json"))).toBe(true);
|
|
135
|
-
expect(result.manifest.resources.length).toBe(
|
|
158
|
+
expect(result.manifest.resources.length).toBe(6);
|
|
136
159
|
});
|
|
137
160
|
|
|
138
161
|
it("emits the /api/{app}/v1/{model}/ contract with FK + sync extensions", () => {
|
|
@@ -162,6 +185,20 @@ describe("rad compile — OpenAPI emission", () => {
|
|
|
162
185
|
expect(
|
|
163
186
|
doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
|
|
164
187
|
).toBe(true);
|
|
188
|
+
|
|
189
|
+
// Access-controlled resource carries the access extension on every op.
|
|
190
|
+
const itineraryList = doc.paths["/api/airports/v1/itinerary/"];
|
|
191
|
+
expect(itineraryList.get["x-rastack-access"]).toEqual({
|
|
192
|
+
ownerField: "owner",
|
|
193
|
+
scope: "owner",
|
|
194
|
+
});
|
|
195
|
+
expect(
|
|
196
|
+
doc.paths["/api/airports/v1/itinerary/{id}/"].delete["x-rastack-access"],
|
|
197
|
+
).toEqual({ ownerField: "owner", scope: "owner" });
|
|
198
|
+
// Unscoped resources stay unstamped.
|
|
199
|
+
expect(
|
|
200
|
+
doc.paths["/api/airports/v1/airport/"].get["x-rastack-access"],
|
|
201
|
+
).toBeUndefined();
|
|
165
202
|
});
|
|
166
203
|
|
|
167
204
|
it("round-trips through the existing hooks codegen", async () => {
|