rastack 0.0.50 → 0.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/auth/context.ts +73 -0
- package/auth/core.ts +293 -0
- package/auth/index.ts +23 -0
- package/components/auto-form/AutoForm.tsx +32 -12
- package/components/auto-form/use-auto-form.ts +75 -6
- package/components/data-table/DataTable.tsx +72 -45
- package/components/data-table/use-data-table.ts +57 -3
- package/components/index.ts +2 -0
- package/db.ts +9 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +1 -7
- package/dist/compile/analyze.js +19 -70
- package/dist/compile/entities.js +163 -16
- package/dist/compile/model.d.ts +7 -3
- package/dist/compile/openapi.js +13 -3
- package/dist/compile/program.d.ts +2 -1
- package/dist/compile/program.js +7 -2
- package/dist/define/auth.d.ts +27 -0
- package/dist/define/auth.js +42 -0
- package/dist/define/db.d.ts +24 -1
- package/dist/define/db.js +1 -1
- package/dist/define/index.d.ts +82 -18
- package/dist/define/index.js +27 -21
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +259 -0
- package/dist/define/markers.d.ts +80 -0
- package/dist/define/markers.js +37 -0
- package/dist/define/types.d.ts +10 -0
- package/dist/define/types.js +26 -0
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +18 -6
- package/hooks/entity.ts +11 -5
- package/hooks/form/entity-form.ts +12 -8
- package/hooks/manifest.ts +50 -14
- package/hooks/registry.ts +25 -8
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/index.ts +1 -5
- package/provider/provider.tsx +61 -16
- package/provider/types.ts +26 -7
- package/provider/warehouse.ts +4 -3
- package/provider/wasm.ts +3 -1
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +27 -65
- package/src/compile/entities.ts +193 -19
- package/src/compile/model.ts +8 -9
- package/src/compile/openapi.ts +11 -3
- package/src/compile/program.ts +10 -3
- package/src/define/auth.ts +28 -0
- package/src/define/db.ts +26 -4
- package/src/define/index.ts +118 -24
- package/src/define/manifest.ts +287 -0
- package/src/define/markers.ts +83 -0
- package/src/define/types.ts +11 -0
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- package/test/auth.spec.ts +207 -0
- package/test/compile.spec.ts +18 -7
- package/test/components.spec.ts +116 -0
- package/test/entities.spec.ts +123 -6
- package/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/typed-hooks.spec.ts +7 -2
- package/types.ts +7 -2
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import { ResourceModel } from "./model";
|
|
3
3
|
import type { EntityTypeExport } from "./entities";
|
|
4
|
+
export { applyTransitions } from "../define/manifest";
|
|
4
5
|
/**
|
|
5
6
|
* Statically analyse resource definitions and produce the canonical model.
|
|
6
7
|
*
|
|
@@ -21,13 +22,6 @@ export declare function analyzeDsl(program: ts.Program, fileNames: string[]): {
|
|
|
21
22
|
resources: ResourceModel[];
|
|
22
23
|
types: EntityTypeExport[];
|
|
23
24
|
};
|
|
24
|
-
/**
|
|
25
|
-
* Attach a normalised `transitions` block to a resource and derive what the
|
|
26
|
-
* machine implies about its state field: the `states` become the field's
|
|
27
|
-
* allowed-value set (`enum` in OpenAPI, the `member` gate in validation), and
|
|
28
|
-
* `initial` becomes its default when the field doesn't declare one.
|
|
29
|
-
*/
|
|
30
|
-
export declare function applyTransitions(resource: ResourceModel, raw: unknown): void;
|
|
31
25
|
/**
|
|
32
26
|
* Evaluate a *static literal* AST node (string/number/bool/null/array/object)
|
|
33
27
|
* into a JS value. Anything non-literal (a function, an identifier) yields
|
package/dist/compile/analyze.js
CHANGED
|
@@ -33,14 +33,20 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.applyTransitions = void 0;
|
|
36
37
|
exports.analyze = analyze;
|
|
37
38
|
exports.analyzeDsl = analyzeDsl;
|
|
38
|
-
exports.applyTransitions = applyTransitions;
|
|
39
39
|
exports.literalToValue = literalToValue;
|
|
40
40
|
const path = __importStar(require("path"));
|
|
41
41
|
const ts = __importStar(require("typescript"));
|
|
42
42
|
const program_1 = require("./program");
|
|
43
|
-
const
|
|
43
|
+
const manifest_1 = require("../define/manifest");
|
|
44
|
+
// The option normalisers live beside the runtime manifest builder
|
|
45
|
+
// (`define/manifest.ts`) and are shared with it, so a manifest built from
|
|
46
|
+
// runtime `resource()` values and one compiled from the same source can
|
|
47
|
+
// never disagree. Re-exported here for existing importers.
|
|
48
|
+
var manifest_2 = require("../define/manifest");
|
|
49
|
+
Object.defineProperty(exports, "applyTransitions", { enumerable: true, get: function () { return manifest_2.applyTransitions; } });
|
|
44
50
|
/**
|
|
45
51
|
* Statically analyse resource definitions and produce the canonical model.
|
|
46
52
|
*
|
|
@@ -79,7 +85,9 @@ function analyzeDsl(program, fileNames) {
|
|
|
79
85
|
});
|
|
80
86
|
}
|
|
81
87
|
// Deterministic order: app, then model.
|
|
82
|
-
resources.sort((a, b) => a.app === b.app
|
|
88
|
+
resources.sort((a, b) => a.app === b.app
|
|
89
|
+
? a.model.localeCompare(b.model)
|
|
90
|
+
: a.app.localeCompare(b.app));
|
|
83
91
|
types.sort((a, b) => a.key.localeCompare(b.key));
|
|
84
92
|
return { resources, types };
|
|
85
93
|
}
|
|
@@ -110,7 +118,10 @@ function analyzeResourceCall(call, checker) {
|
|
|
110
118
|
const app = stringLiteralArg(call.arguments[0]);
|
|
111
119
|
const model = stringLiteralArg(call.arguments[1]);
|
|
112
120
|
const fieldsArg = call.arguments[2];
|
|
113
|
-
if (!app ||
|
|
121
|
+
if (!app ||
|
|
122
|
+
!model ||
|
|
123
|
+
!fieldsArg ||
|
|
124
|
+
!ts.isObjectLiteralExpression(fieldsArg)) {
|
|
114
125
|
return undefined;
|
|
115
126
|
}
|
|
116
127
|
const fields = [];
|
|
@@ -139,35 +150,16 @@ function analyzeResourceCall(call, checker) {
|
|
|
139
150
|
model,
|
|
140
151
|
fields,
|
|
141
152
|
relations,
|
|
142
|
-
sync: normaliseSync(options.sync),
|
|
143
|
-
admin: normaliseAdmin(options.admin),
|
|
153
|
+
sync: (0, manifest_1.normaliseSync)(options.sync),
|
|
154
|
+
admin: (0, manifest_1.normaliseAdmin)(options.admin),
|
|
144
155
|
search: options.search,
|
|
145
156
|
ordering: options.ordering,
|
|
146
157
|
permission: options.permission,
|
|
147
|
-
access: normaliseAccess(options.access),
|
|
158
|
+
access: (0, manifest_1.normaliseAccess)(options.access),
|
|
148
159
|
};
|
|
149
|
-
applyTransitions(resource, options.transitions);
|
|
160
|
+
(0, manifest_1.applyTransitions)(resource, options.transitions);
|
|
150
161
|
return resource;
|
|
151
162
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Attach a normalised `transitions` block to a resource and derive what the
|
|
154
|
-
* machine implies about its state field: the `states` become the field's
|
|
155
|
-
* allowed-value set (`enum` in OpenAPI, the `member` gate in validation), and
|
|
156
|
-
* `initial` becomes its default when the field doesn't declare one.
|
|
157
|
-
*/
|
|
158
|
-
function applyTransitions(resource, raw) {
|
|
159
|
-
const transitions = (0, transitions_1.normalizeTransitions)(raw);
|
|
160
|
-
if (!transitions)
|
|
161
|
-
return;
|
|
162
|
-
resource.transitions = transitions;
|
|
163
|
-
const stateField = resource.fields.find((f) => f.name === transitions.field);
|
|
164
|
-
if (stateField && stateField.type !== "fk") {
|
|
165
|
-
stateField.options = [...transitions.states];
|
|
166
|
-
if (stateField.default === undefined && transitions.initial !== undefined) {
|
|
167
|
-
stateField.default = transitions.initial;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
163
|
/**
|
|
172
164
|
* Resolve a single field from its initializer. FK detection is type-driven:
|
|
173
165
|
* the initializer's *type* is inspected, and a `Ref` type yields a foreign key.
|
|
@@ -278,46 +270,3 @@ function literalToValue(node) {
|
|
|
278
270
|
}
|
|
279
271
|
return undefined;
|
|
280
272
|
}
|
|
281
|
-
function normaliseSync(sync) {
|
|
282
|
-
if (!sync || typeof sync !== "object")
|
|
283
|
-
return undefined;
|
|
284
|
-
const mode = sync.mode === "local" ? "local" : "server";
|
|
285
|
-
if (mode === "server" && !sync.confirmed) {
|
|
286
|
-
// A bare server resource carries no sync semantics.
|
|
287
|
-
return undefined;
|
|
288
|
-
}
|
|
289
|
-
const model = { mode };
|
|
290
|
-
if (typeof sync.scope === "string")
|
|
291
|
-
model.scope = sync.scope;
|
|
292
|
-
if (Array.isArray(sync.confirmed))
|
|
293
|
-
model.confirmed = sync.confirmed;
|
|
294
|
-
if (sync.load === "lazy")
|
|
295
|
-
model.load = "lazy";
|
|
296
|
-
if (sync.clientIds === true)
|
|
297
|
-
model.clientIds = true;
|
|
298
|
-
return model;
|
|
299
|
-
}
|
|
300
|
-
function normaliseAccess(access) {
|
|
301
|
-
if (!access || typeof access !== "object")
|
|
302
|
-
return undefined;
|
|
303
|
-
const model = {};
|
|
304
|
-
if (typeof access.ownerField === "string")
|
|
305
|
-
model.ownerField = access.ownerField;
|
|
306
|
-
if (access.scope === "owner" || access.scope === "shared") {
|
|
307
|
-
model.scope = access.scope;
|
|
308
|
-
}
|
|
309
|
-
if (Array.isArray(access.adminGroups)) {
|
|
310
|
-
model.adminGroups = access.adminGroups.filter((g) => typeof g === "string");
|
|
311
|
-
}
|
|
312
|
-
return Object.keys(model).length ? model : undefined;
|
|
313
|
-
}
|
|
314
|
-
function normaliseAdmin(admin) {
|
|
315
|
-
if (admin === false)
|
|
316
|
-
return false;
|
|
317
|
-
if (!admin || typeof admin !== "object")
|
|
318
|
-
return undefined;
|
|
319
|
-
const model = {};
|
|
320
|
-
if (Array.isArray(admin.listDisplay))
|
|
321
|
-
model.listDisplay = admin.listDisplay;
|
|
322
|
-
return model;
|
|
323
|
-
}
|
package/dist/compile/entities.js
CHANGED
|
@@ -107,8 +107,9 @@ function analyzeEntities(program, fileNames) {
|
|
|
107
107
|
for (const decl of candidates) {
|
|
108
108
|
if (jsdocTags(decl).has("rastackresource"))
|
|
109
109
|
addRoot(decl);
|
|
110
|
-
else if (ts.isClassDeclaration(decl) && isDataClass(decl))
|
|
110
|
+
else if (ts.isClassDeclaration(decl) && isDataClass(decl, checker)) {
|
|
111
111
|
addRoot(decl);
|
|
112
|
+
}
|
|
112
113
|
}
|
|
113
114
|
// 2. Transitive closure over FK references: an entity's entity-typed
|
|
114
115
|
// property pulls the referenced declaration in as an entity too.
|
|
@@ -131,7 +132,9 @@ function analyzeEntities(program, fileNames) {
|
|
|
131
132
|
seen.add(decl);
|
|
132
133
|
included.push(decl);
|
|
133
134
|
for (const prop of analyzed(decl)) {
|
|
134
|
-
if (prop.target &&
|
|
135
|
+
if (prop.target &&
|
|
136
|
+
candidates.has(prop.target) &&
|
|
137
|
+
!seen.has(prop.target)) {
|
|
135
138
|
queue.push(prop.target);
|
|
136
139
|
}
|
|
137
140
|
}
|
|
@@ -152,9 +155,14 @@ function analyzeEntities(program, fileNames) {
|
|
|
152
155
|
});
|
|
153
156
|
const fields = [];
|
|
154
157
|
const relations = [];
|
|
158
|
+
let ownerField;
|
|
159
|
+
let groupField;
|
|
155
160
|
for (const prop of analyzed(decl)) {
|
|
156
161
|
if (prop.skip) {
|
|
157
|
-
diagnostics.push({
|
|
162
|
+
diagnostics.push({
|
|
163
|
+
severity: "warning",
|
|
164
|
+
message: `${key}.${prop.name}: ${prop.skip}`,
|
|
165
|
+
});
|
|
158
166
|
continue;
|
|
159
167
|
}
|
|
160
168
|
if (prop.inverse)
|
|
@@ -179,15 +187,57 @@ function analyzeEntities(program, fileNames) {
|
|
|
179
187
|
}
|
|
180
188
|
else if (prop.field) {
|
|
181
189
|
fields.push(prop.field);
|
|
190
|
+
if (prop.owner) {
|
|
191
|
+
if (ownerField) {
|
|
192
|
+
diagnostics.push({
|
|
193
|
+
severity: "warning",
|
|
194
|
+
message: `${key}: multiple Owner fields — keeping "${ownerField}", ignoring "${prop.name}".`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
ownerField = prop.name;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (prop.group) {
|
|
202
|
+
if (groupField) {
|
|
203
|
+
diagnostics.push({
|
|
204
|
+
severity: "warning",
|
|
205
|
+
message: `${key}: multiple Group fields — keeping "${groupField}", ignoring "${prop.name}".`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
groupField = prop.name;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
182
212
|
}
|
|
183
213
|
}
|
|
214
|
+
const policy = entityPolicy(decl, checker, naming);
|
|
215
|
+
let scope = policy.scope;
|
|
216
|
+
if (!scope && groupField)
|
|
217
|
+
scope = "group"; // group datasets partition physically by default
|
|
218
|
+
if (scope === "group" && !groupField) {
|
|
219
|
+
diagnostics.push({
|
|
220
|
+
severity: "warning",
|
|
221
|
+
message: `${key}: GroupScoped without a Group field — add a \`string & Group\` property so rows can be placed in a partition.`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const access = {};
|
|
225
|
+
if (ownerField)
|
|
226
|
+
access.ownerField = ownerField;
|
|
227
|
+
if (groupField)
|
|
228
|
+
access.groupField = groupField;
|
|
229
|
+
if (scope)
|
|
230
|
+
access.scope = scope;
|
|
231
|
+
if (policy.adminGroups?.length)
|
|
232
|
+
access.adminGroups = policy.adminGroups;
|
|
184
233
|
const resource = {
|
|
185
234
|
app: naming.app,
|
|
186
235
|
model: naming.model,
|
|
187
236
|
fields,
|
|
188
237
|
relations,
|
|
189
238
|
search: naming.search,
|
|
190
|
-
permission:
|
|
239
|
+
permission: policy.permission,
|
|
240
|
+
access: Object.keys(access).length ? access : undefined,
|
|
191
241
|
};
|
|
192
242
|
// A class may declare its state machine inline: `static transitions =
|
|
193
243
|
// {...}`. Statics are never columns, so the block rides alongside the
|
|
@@ -195,7 +245,9 @@ function analyzeEntities(program, fileNames) {
|
|
|
195
245
|
(0, analyze_1.applyTransitions)(resource, staticTransitions(decl));
|
|
196
246
|
resources.push(resource);
|
|
197
247
|
}
|
|
198
|
-
resources.sort((a, b) => a.app === b.app
|
|
248
|
+
resources.sort((a, b) => a.app === b.app
|
|
249
|
+
? a.model.localeCompare(b.model)
|
|
250
|
+
: a.app.localeCompare(b.app));
|
|
199
251
|
return { resources, diagnostics, types };
|
|
200
252
|
}
|
|
201
253
|
/**
|
|
@@ -263,14 +315,23 @@ function entityBehindType(type, scanned) {
|
|
|
263
315
|
/**
|
|
264
316
|
* A *plain data class* — the class-authored equivalent of a `resource()` call,
|
|
265
317
|
* so it roots itself. It must carry no heritage clause (a subclass of
|
|
266
|
-
* `React.Component` or a service base is app code, not a table)
|
|
267
|
-
*
|
|
268
|
-
*
|
|
318
|
+
* `React.Component` or a service base is app code, not a table) — with one
|
|
319
|
+
* exception: `implements` clauses made **only of auth markers**
|
|
320
|
+
* (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, …) are policy, not
|
|
321
|
+
* behaviour, and keep the class a plain data class. It must not be abstract,
|
|
322
|
+
* contain nothing but property declarations, and have at least one instance
|
|
323
|
+
* property. Anything richer can still opt in via a hook or the
|
|
269
324
|
* `@rastackResource` tag.
|
|
270
325
|
*/
|
|
271
|
-
function isDataClass(decl) {
|
|
272
|
-
|
|
273
|
-
|
|
326
|
+
function isDataClass(decl, checker) {
|
|
327
|
+
for (const clause of decl.heritageClauses ?? []) {
|
|
328
|
+
if (clause.token !== ts.SyntaxKind.ImplementsKeyword)
|
|
329
|
+
return false;
|
|
330
|
+
for (const typeNode of clause.types) {
|
|
331
|
+
if (!isAccessMarker(checker.getTypeFromTypeNode(typeNode)))
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
274
335
|
if (ts.getCombinedModifierFlags(decl) &
|
|
275
336
|
(ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)) {
|
|
276
337
|
return false;
|
|
@@ -337,7 +398,8 @@ function analyzeProperty(name, member, checker, scanned) {
|
|
|
337
398
|
let typeNode = unwrapParens(member.type);
|
|
338
399
|
if (ts.isUnionTypeNode(typeNode)) {
|
|
339
400
|
const arms = typeNode.types.filter((t) => {
|
|
340
|
-
const isNullish = (ts.isLiteralTypeNode(t) &&
|
|
401
|
+
const isNullish = (ts.isLiteralTypeNode(t) &&
|
|
402
|
+
t.literal.kind === ts.SyntaxKind.NullKeyword) ||
|
|
341
403
|
t.kind === ts.SyntaxKind.UndefinedKeyword;
|
|
342
404
|
if (isNullish)
|
|
343
405
|
nullable = true;
|
|
@@ -449,7 +511,12 @@ function scalarProp(name, base, config, nullable) {
|
|
|
449
511
|
field.null = true;
|
|
450
512
|
if (config.default !== undefined)
|
|
451
513
|
field.default = config.default;
|
|
452
|
-
|
|
514
|
+
const info = { name, nullable, field };
|
|
515
|
+
if (config.owner)
|
|
516
|
+
info.owner = true;
|
|
517
|
+
if (config.group)
|
|
518
|
+
info.group = true;
|
|
519
|
+
return info;
|
|
453
520
|
}
|
|
454
521
|
/**
|
|
455
522
|
* Config carried in the type system: intersecting a property type with
|
|
@@ -479,6 +546,10 @@ function configFromType(type, checker, location) {
|
|
|
479
546
|
config.unique = true;
|
|
480
547
|
if (prop("primaryKey"))
|
|
481
548
|
config.primaryKey = true;
|
|
549
|
+
if (prop("owner"))
|
|
550
|
+
config.owner = true;
|
|
551
|
+
if (prop("group"))
|
|
552
|
+
config.group = true;
|
|
482
553
|
const maxLength = prop("maxLength");
|
|
483
554
|
if (maxLength?.isNumberLiteral())
|
|
484
555
|
config.maxLength = maxLength.value;
|
|
@@ -501,6 +572,10 @@ function configFromTags(tags) {
|
|
|
501
572
|
config.unique = true;
|
|
502
573
|
if (tags.has("primarykey"))
|
|
503
574
|
config.primaryKey = true;
|
|
575
|
+
if (tags.has("owner"))
|
|
576
|
+
config.owner = true;
|
|
577
|
+
if (tags.has("group"))
|
|
578
|
+
config.group = true;
|
|
504
579
|
const maxLength = Number(tags.get("maxlength"));
|
|
505
580
|
if (Number.isFinite(maxLength) && maxLength > 0)
|
|
506
581
|
config.maxLength = maxLength;
|
|
@@ -543,7 +618,8 @@ function arrayElement(node) {
|
|
|
543
618
|
return node.elementType;
|
|
544
619
|
if (ts.isTypeReferenceNode(node) &&
|
|
545
620
|
ts.isIdentifier(node.typeName) &&
|
|
546
|
-
(node.typeName.text === "Array" ||
|
|
621
|
+
(node.typeName.text === "Array" ||
|
|
622
|
+
node.typeName.text === "ReadonlyArray") &&
|
|
547
623
|
node.typeArguments?.length === 1) {
|
|
548
624
|
return node.typeArguments[0];
|
|
549
625
|
}
|
|
@@ -556,7 +632,8 @@ function entityNaming(decl) {
|
|
|
556
632
|
// Generated entity interfaces are `I`-prefixed (`IFlight`); strip the prefix
|
|
557
633
|
// so hook usage of generated types resolves to the same resource.
|
|
558
634
|
const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
|
|
559
|
-
const model = tags.get("rastackmodel") ||
|
|
635
|
+
const model = tags.get("rastackmodel") ||
|
|
636
|
+
stripped.charAt(0).toLowerCase() + stripped.slice(1);
|
|
560
637
|
const app = tags.get("rastackapp") ||
|
|
561
638
|
path
|
|
562
639
|
.basename(decl.getSourceFile().fileName)
|
|
@@ -565,7 +642,10 @@ function entityNaming(decl) {
|
|
|
565
642
|
const naming = { app, model };
|
|
566
643
|
const search = tags.get("rastacksearch");
|
|
567
644
|
if (search)
|
|
568
|
-
naming.search = search
|
|
645
|
+
naming.search = search
|
|
646
|
+
.split(",")
|
|
647
|
+
.map((s) => s.trim())
|
|
648
|
+
.filter(Boolean);
|
|
569
649
|
const permission = tags.get("rastackpermission");
|
|
570
650
|
if (permission === "authenticatedOrReadOnly" ||
|
|
571
651
|
permission === "authenticated" ||
|
|
@@ -574,6 +654,73 @@ function entityNaming(decl) {
|
|
|
574
654
|
}
|
|
575
655
|
return naming;
|
|
576
656
|
}
|
|
657
|
+
// -- access policy (auth as types) ---------------------------------------------
|
|
658
|
+
/** The phantom keys the `rastack/auth` entity markers carry. */
|
|
659
|
+
const MARKER_PROPS = [
|
|
660
|
+
"__rastackPermission",
|
|
661
|
+
"__rastackAdminGroups",
|
|
662
|
+
"__rastackScope",
|
|
663
|
+
];
|
|
664
|
+
/** Whether a heritage type is an auth marker (policy, not behaviour). */
|
|
665
|
+
function isAccessMarker(type) {
|
|
666
|
+
return MARKER_PROPS.some((p) => type.getProperty(p));
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Read the entity's access policy: the auth markers on its heritage clauses
|
|
670
|
+
* (`implements IsAuthenticated, AdminGroups<"staff">` on a class, `extends`
|
|
671
|
+
* on an interface) merged over the JSDoc tag equivalents
|
|
672
|
+
* (`@rastackPermission`, `@rastackAdminGroups`, `@rastackScope`) — the type
|
|
673
|
+
* wins when both are present, like the `DbConfig` brands do for columns.
|
|
674
|
+
*/
|
|
675
|
+
function entityPolicy(decl, checker, naming) {
|
|
676
|
+
const policy = {};
|
|
677
|
+
const tags = jsdocTags(decl);
|
|
678
|
+
const adminGroupsTag = tags.get("rastackadmingroups");
|
|
679
|
+
if (adminGroupsTag) {
|
|
680
|
+
const groups = adminGroupsTag
|
|
681
|
+
.split(",")
|
|
682
|
+
.map((g) => g.trim())
|
|
683
|
+
.filter(Boolean);
|
|
684
|
+
if (groups.length)
|
|
685
|
+
policy.adminGroups = groups;
|
|
686
|
+
}
|
|
687
|
+
const scopeTag = tags.get("rastackscope");
|
|
688
|
+
if (scopeTag === "owner" || scopeTag === "group" || scopeTag === "shared") {
|
|
689
|
+
policy.scope = scopeTag;
|
|
690
|
+
}
|
|
691
|
+
policy.permission = naming.permission; // @rastackPermission, parsed with naming
|
|
692
|
+
for (const clause of decl.heritageClauses ?? []) {
|
|
693
|
+
for (const typeNode of clause.types) {
|
|
694
|
+
const type = checker.getTypeFromTypeNode(typeNode);
|
|
695
|
+
const literals = (prop) => {
|
|
696
|
+
const symbol = type.getProperty(prop);
|
|
697
|
+
if (!symbol)
|
|
698
|
+
return [];
|
|
699
|
+
const carrier = checker
|
|
700
|
+
.getTypeOfSymbolAtLocation(symbol, decl)
|
|
701
|
+
.getNonNullableType();
|
|
702
|
+
const arms = carrier.isUnion() ? carrier.types : [carrier];
|
|
703
|
+
return arms
|
|
704
|
+
.filter((t) => t.isStringLiteral())
|
|
705
|
+
.map((t) => t.value);
|
|
706
|
+
};
|
|
707
|
+
const [permission] = literals("__rastackPermission");
|
|
708
|
+
if (permission === "authenticated" ||
|
|
709
|
+
permission === "authenticatedOrReadOnly" ||
|
|
710
|
+
permission === "public") {
|
|
711
|
+
policy.permission = permission;
|
|
712
|
+
}
|
|
713
|
+
const adminGroups = literals("__rastackAdminGroups");
|
|
714
|
+
if (adminGroups.length)
|
|
715
|
+
policy.adminGroups = adminGroups.sort();
|
|
716
|
+
const [scope] = literals("__rastackScope");
|
|
717
|
+
if (scope === "owner" || scope === "group" || scope === "shared") {
|
|
718
|
+
policy.scope = scope;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return policy;
|
|
723
|
+
}
|
|
577
724
|
/** All JSDoc tags on a node, keyed by lower-cased tag name → comment text. */
|
|
578
725
|
function jsdocTags(node) {
|
|
579
726
|
const tags = new Map();
|
package/dist/compile/model.d.ts
CHANGED
|
@@ -46,12 +46,16 @@ export interface AdminModel {
|
|
|
46
46
|
/**
|
|
47
47
|
* Row-level access control, enforced by `rastack-api-core` on every surface.
|
|
48
48
|
* `ownerField` names the field stamped with the authenticated subject;
|
|
49
|
-
* `
|
|
50
|
-
*
|
|
49
|
+
* `groupField` names the field holding the owning group (rows visible to its
|
|
50
|
+
* members). `scope: "owner"` places the table under a per-identity warehouse
|
|
51
|
+
* prefix (`tenants/{sub}/…`) so object-storage IAM can fence data per user;
|
|
52
|
+
* `scope: "group"` partitions per group (`groups/{name}/…`) so each group's
|
|
53
|
+
* records are physically stored apart.
|
|
51
54
|
*/
|
|
52
55
|
export interface AccessModel {
|
|
53
56
|
ownerField?: string;
|
|
54
|
-
|
|
57
|
+
groupField?: string;
|
|
58
|
+
scope?: "owner" | "group" | "shared";
|
|
55
59
|
adminGroups?: string[];
|
|
56
60
|
}
|
|
57
61
|
/**
|
package/dist/compile/openapi.js
CHANGED
|
@@ -225,19 +225,29 @@ function syncStamp(operation, resource, verb) {
|
|
|
225
225
|
return operation;
|
|
226
226
|
}
|
|
227
227
|
/**
|
|
228
|
-
* Stamp `x-rastack-access` on an operation
|
|
229
|
-
* downstream tooling
|
|
230
|
-
* the
|
|
228
|
+
* Stamp `x-rastack-permission` / `x-rastack-access` on an operation so
|
|
229
|
+
* downstream tooling knows the resource's full access policy: the permission
|
|
230
|
+
* gate, the owner field (row-level security), tenant scope (the local cache
|
|
231
|
+
* partitions per identity), and the RLS-bypassing admin groups — everything
|
|
232
|
+
* `rastack/auth`'s `policyFromOpenApi` needs to mirror the server's rules in
|
|
233
|
+
* the UI.
|
|
231
234
|
*/
|
|
232
235
|
function accessStamp(operation, resource) {
|
|
236
|
+
if (resource.permission) {
|
|
237
|
+
operation["x-rastack-permission"] = resource.permission;
|
|
238
|
+
}
|
|
233
239
|
const access = resource.access;
|
|
234
240
|
if (!access)
|
|
235
241
|
return;
|
|
236
242
|
const stamp = {};
|
|
237
243
|
if (access.ownerField)
|
|
238
244
|
stamp.ownerField = access.ownerField;
|
|
245
|
+
if (access.groupField)
|
|
246
|
+
stamp.groupField = access.groupField;
|
|
239
247
|
if (access.scope)
|
|
240
248
|
stamp.scope = access.scope;
|
|
249
|
+
if (access.adminGroups?.length)
|
|
250
|
+
stamp.adminGroups = access.adminGroups;
|
|
241
251
|
if (Object.keys(stamp).length)
|
|
242
252
|
operation["x-rastack-access"] = stamp;
|
|
243
253
|
}
|
|
@@ -2,7 +2,8 @@ import * as ts from "typescript";
|
|
|
2
2
|
/**
|
|
3
3
|
* Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
|
|
4
4
|
* definition files. The authoring subpaths (`rastack/define` for the DSL,
|
|
5
|
-
* `rastack/
|
|
5
|
+
* `rastack/db` for the `DbConfig` column brands, `rastack/auth` for the auth
|
|
6
|
+
* markers, `rastack/types` as the alias for both) are mapped to the
|
|
6
7
|
* in-tree/installed sources via `paths` so both the shipped package name and
|
|
7
8
|
* the local demo/tests resolve without extra linking.
|
|
8
9
|
*/
|
package/dist/compile/program.js
CHANGED
|
@@ -40,7 +40,8 @@ const ts = __importStar(require("typescript"));
|
|
|
40
40
|
/**
|
|
41
41
|
* Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
|
|
42
42
|
* definition files. The authoring subpaths (`rastack/define` for the DSL,
|
|
43
|
-
* `rastack/
|
|
43
|
+
* `rastack/db` for the `DbConfig` column brands, `rastack/auth` for the auth
|
|
44
|
+
* markers, `rastack/types` as the alias for both) are mapped to the
|
|
44
45
|
* in-tree/installed sources via `paths` so both the shipped package name and
|
|
45
46
|
* the local demo/tests resolve without extra linking.
|
|
46
47
|
*/
|
|
@@ -61,7 +62,11 @@ function createProgram(fileNames) {
|
|
|
61
62
|
paths: {
|
|
62
63
|
"rastack/define": [defineDir],
|
|
63
64
|
"rastack/define/*": [defineDir + "/*"],
|
|
64
|
-
|
|
65
|
+
// `rastack/db` = column brands, `rastack/auth` = auth markers + the
|
|
66
|
+
// Owner/Group brands; `rastack/types` is the compatibility alias for both.
|
|
67
|
+
"rastack/db": [path.join(defineDir, "db")],
|
|
68
|
+
"rastack/auth": [path.join(defineDir, "auth")],
|
|
69
|
+
"rastack/types": [path.join(defineDir, "types")],
|
|
65
70
|
},
|
|
66
71
|
};
|
|
67
72
|
return ts.createProgram(fileNames, options);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `rastack/auth` *authoring* surface — auth as TypeScript types.
|
|
3
|
+
*
|
|
4
|
+
* Everything needed to declare an entity's access policy in the type system:
|
|
5
|
+
* the entity-level markers (`IsAuthenticated`, `AdminGroups<…>`,
|
|
6
|
+
* `OwnerScoped`, …) and the access-control field brands (`Owner`, `Group`).
|
|
7
|
+
* The compiler maps the `rastack/auth` import specifier here (see
|
|
8
|
+
* `compile/program.ts`); the published package additionally exposes the
|
|
9
|
+
* client runtime (`RastackAuth`, `can`, the React context) under the same
|
|
10
|
+
* subpath from `tools/auth/`.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { AdminGroups, Group, IsAuthenticated, Owner, OwnerScoped } from "rastack/auth";
|
|
14
|
+
*
|
|
15
|
+
* export class Trip implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped {
|
|
16
|
+
* owner!: string & Owner;
|
|
17
|
+
* destination!: string;
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* export class Report implements IsAuthenticated, AdminGroups<"staff"> {
|
|
21
|
+
* team!: string & Group; // group-owned — physically partitioned per group
|
|
22
|
+
* title!: string;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export * from "./markers";
|
|
27
|
+
export type { Owner, Group } from "./db";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The `rastack/auth` *authoring* surface — auth as TypeScript types.
|
|
4
|
+
*
|
|
5
|
+
* Everything needed to declare an entity's access policy in the type system:
|
|
6
|
+
* the entity-level markers (`IsAuthenticated`, `AdminGroups<…>`,
|
|
7
|
+
* `OwnerScoped`, …) and the access-control field brands (`Owner`, `Group`).
|
|
8
|
+
* The compiler maps the `rastack/auth` import specifier here (see
|
|
9
|
+
* `compile/program.ts`); the published package additionally exposes the
|
|
10
|
+
* client runtime (`RastackAuth`, `can`, the React context) under the same
|
|
11
|
+
* subpath from `tools/auth/`.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { AdminGroups, Group, IsAuthenticated, Owner, OwnerScoped } from "rastack/auth";
|
|
15
|
+
*
|
|
16
|
+
* export class Trip implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped {
|
|
17
|
+
* owner!: string & Owner;
|
|
18
|
+
* destination!: string;
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* export class Report implements IsAuthenticated, AdminGroups<"staff"> {
|
|
22
|
+
* team!: string & Group; // group-owned — physically partitioned per group
|
|
23
|
+
* title!: string;
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
39
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
__exportStar(require("./markers"), exports);
|
package/dist/define/db.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* off the type — the same way it reads foreign keys.
|
|
8
8
|
*
|
|
9
9
|
* ```ts
|
|
10
|
-
* import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/
|
|
10
|
+
* import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/db";
|
|
11
11
|
*
|
|
12
12
|
* export interface Passenger {
|
|
13
13
|
* id: UUID & PrimaryKey;
|
|
@@ -59,6 +59,29 @@ export interface Default<V extends string | number | boolean> extends DbConfig<{
|
|
|
59
59
|
default: V;
|
|
60
60
|
}> {
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Marks the column that records the owning identity (the verified token
|
|
64
|
+
* `sub`): `owner: string & Owner`. Compiles to `access.ownerField` — the row
|
|
65
|
+
* is stamped from the token on create, immutable after, and rows are only
|
|
66
|
+
* visible to their owner (plus `AdminGroups` members). Add the `OwnerScoped`
|
|
67
|
+
* entity marker to *also* partition the table physically per identity.
|
|
68
|
+
*/
|
|
69
|
+
export interface Owner extends DbConfig<{
|
|
70
|
+
owner: true;
|
|
71
|
+
}> {
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Marks the column that records the owning group (a verified `cognito:groups`
|
|
75
|
+
* value): `team: string & Group`. Compiles to `access.groupField` — rows are
|
|
76
|
+
* only visible to members of their group, and the dataset is **physically
|
|
77
|
+
* partitioned per group** by default (`groups/{name}/…` on object storage),
|
|
78
|
+
* so each group's records live in their own files. Add the `SharedStorage`
|
|
79
|
+
* entity marker to keep group rows in one shared table instead.
|
|
80
|
+
*/
|
|
81
|
+
export interface Group extends DbConfig<{
|
|
82
|
+
group: true;
|
|
83
|
+
}> {
|
|
84
|
+
}
|
|
62
85
|
/** An integer column — a bare `number` property maps to `float`. */
|
|
63
86
|
export type Int = number & DbConfig<{
|
|
64
87
|
int: true;
|
package/dist/define/db.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* off the type — the same way it reads foreign keys.
|
|
9
9
|
*
|
|
10
10
|
* ```ts
|
|
11
|
-
* import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/
|
|
11
|
+
* import { Default, Int, MaxLength, PrimaryKey, Unique, UUID } from "rastack/db";
|
|
12
12
|
*
|
|
13
13
|
* export interface Passenger {
|
|
14
14
|
* id: UUID & PrimaryKey;
|