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
package/src/compile/entities.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { applyTransitions, literalToValue } from "./analyze";
|
|
|
25
25
|
* 2. **Datatypes come from the properties.** Each property's TypeScript type
|
|
26
26
|
* maps to a manifest scalar (`string` → string, `number` → float,
|
|
27
27
|
* `boolean` → bool, `Date` → datetime). Constraints are carried in the
|
|
28
|
-
* type system with `DbConfig` brands from `rastack/
|
|
28
|
+
* type system with `DbConfig` brands from `rastack/db` —
|
|
29
29
|
* `code: string & Unique & MaxLength<3>`, `age: Int` — or, equivalently,
|
|
30
30
|
* as JSDoc tags (`@unique`, `@maxLength 3`, `@int`, `@uuid`, `@default`).
|
|
31
31
|
* Optional / `| null` properties become nullable fields. On a class, a
|
|
@@ -166,7 +166,9 @@ export function analyzeEntities(
|
|
|
166
166
|
}
|
|
167
167
|
for (const decl of candidates) {
|
|
168
168
|
if (jsdocTags(decl).has("rastackresource")) addRoot(decl);
|
|
169
|
-
else if (ts.isClassDeclaration(decl) && isDataClass(decl))
|
|
169
|
+
else if (ts.isClassDeclaration(decl) && isDataClass(decl, checker)) {
|
|
170
|
+
addRoot(decl);
|
|
171
|
+
}
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
// 2. Transitive closure over FK references: an entity's entity-typed
|
|
@@ -190,7 +192,11 @@ export function analyzeEntities(
|
|
|
190
192
|
seen.add(decl);
|
|
191
193
|
included.push(decl);
|
|
192
194
|
for (const prop of analyzed(decl)) {
|
|
193
|
-
if (
|
|
195
|
+
if (
|
|
196
|
+
prop.target &&
|
|
197
|
+
candidates.has(prop.target) &&
|
|
198
|
+
!seen.has(prop.target)
|
|
199
|
+
) {
|
|
194
200
|
queue.push(prop.target);
|
|
195
201
|
}
|
|
196
202
|
}
|
|
@@ -215,9 +221,14 @@ export function analyzeEntities(
|
|
|
215
221
|
const fields: FieldModel[] = [];
|
|
216
222
|
const relations: RelationModel[] = [];
|
|
217
223
|
|
|
224
|
+
let ownerField: string | undefined;
|
|
225
|
+
let groupField: string | undefined;
|
|
218
226
|
for (const prop of analyzed(decl)) {
|
|
219
227
|
if (prop.skip) {
|
|
220
|
-
diagnostics.push({
|
|
228
|
+
diagnostics.push({
|
|
229
|
+
severity: "warning",
|
|
230
|
+
message: `${key}.${prop.name}: ${prop.skip}`,
|
|
231
|
+
});
|
|
221
232
|
continue;
|
|
222
233
|
}
|
|
223
234
|
if (prop.inverse) continue; // has-many arrays are the reverse side of a FK
|
|
@@ -238,16 +249,53 @@ export function analyzeEntities(
|
|
|
238
249
|
});
|
|
239
250
|
} else if (prop.field) {
|
|
240
251
|
fields.push(prop.field);
|
|
252
|
+
if (prop.owner) {
|
|
253
|
+
if (ownerField) {
|
|
254
|
+
diagnostics.push({
|
|
255
|
+
severity: "warning",
|
|
256
|
+
message: `${key}: multiple Owner fields — keeping "${ownerField}", ignoring "${prop.name}".`,
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
ownerField = prop.name;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (prop.group) {
|
|
263
|
+
if (groupField) {
|
|
264
|
+
diagnostics.push({
|
|
265
|
+
severity: "warning",
|
|
266
|
+
message: `${key}: multiple Group fields — keeping "${groupField}", ignoring "${prop.name}".`,
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
groupField = prop.name;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
241
272
|
}
|
|
242
273
|
}
|
|
243
274
|
|
|
275
|
+
const policy = entityPolicy(decl, checker, naming);
|
|
276
|
+
let scope = policy.scope;
|
|
277
|
+
if (!scope && groupField) scope = "group"; // group datasets partition physically by default
|
|
278
|
+
if (scope === "group" && !groupField) {
|
|
279
|
+
diagnostics.push({
|
|
280
|
+
severity: "warning",
|
|
281
|
+
message: `${key}: GroupScoped without a Group field — add a \`string & Group\` property so rows can be placed in a partition.`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const access: NonNullable<ResourceModel["access"]> = {};
|
|
286
|
+
if (ownerField) access.ownerField = ownerField;
|
|
287
|
+
if (groupField) access.groupField = groupField;
|
|
288
|
+
if (scope) access.scope = scope;
|
|
289
|
+
if (policy.adminGroups?.length) access.adminGroups = policy.adminGroups;
|
|
290
|
+
|
|
244
291
|
const resource: ResourceModel = {
|
|
245
292
|
app: naming.app,
|
|
246
293
|
model: naming.model,
|
|
247
294
|
fields,
|
|
248
295
|
relations,
|
|
249
296
|
search: naming.search,
|
|
250
|
-
permission:
|
|
297
|
+
permission: policy.permission,
|
|
298
|
+
access: Object.keys(access).length ? access : undefined,
|
|
251
299
|
};
|
|
252
300
|
// A class may declare its state machine inline: `static transitions =
|
|
253
301
|
// {...}`. Statics are never columns, so the block rides alongside the
|
|
@@ -257,7 +305,9 @@ export function analyzeEntities(
|
|
|
257
305
|
}
|
|
258
306
|
|
|
259
307
|
resources.sort((a, b) =>
|
|
260
|
-
a.app === b.app
|
|
308
|
+
a.app === b.app
|
|
309
|
+
? a.model.localeCompare(b.model)
|
|
310
|
+
: a.app.localeCompare(b.app),
|
|
261
311
|
);
|
|
262
312
|
return { resources, diagnostics, types };
|
|
263
313
|
}
|
|
@@ -344,13 +394,24 @@ function entityBehindType(
|
|
|
344
394
|
/**
|
|
345
395
|
* A *plain data class* — the class-authored equivalent of a `resource()` call,
|
|
346
396
|
* so it roots itself. It must carry no heritage clause (a subclass of
|
|
347
|
-
* `React.Component` or a service base is app code, not a table)
|
|
348
|
-
*
|
|
349
|
-
*
|
|
397
|
+
* `React.Component` or a service base is app code, not a table) — with one
|
|
398
|
+
* exception: `implements` clauses made **only of auth markers**
|
|
399
|
+
* (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, …) are policy, not
|
|
400
|
+
* behaviour, and keep the class a plain data class. It must not be abstract,
|
|
401
|
+
* contain nothing but property declarations, and have at least one instance
|
|
402
|
+
* property. Anything richer can still opt in via a hook or the
|
|
350
403
|
* `@rastackResource` tag.
|
|
351
404
|
*/
|
|
352
|
-
function isDataClass(
|
|
353
|
-
|
|
405
|
+
function isDataClass(
|
|
406
|
+
decl: ts.ClassDeclaration,
|
|
407
|
+
checker: ts.TypeChecker,
|
|
408
|
+
): boolean {
|
|
409
|
+
for (const clause of decl.heritageClauses ?? []) {
|
|
410
|
+
if (clause.token !== ts.SyntaxKind.ImplementsKeyword) return false;
|
|
411
|
+
for (const typeNode of clause.types) {
|
|
412
|
+
if (!isAccessMarker(checker.getTypeFromTypeNode(typeNode))) return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
354
415
|
if (
|
|
355
416
|
ts.getCombinedModifierFlags(decl) &
|
|
356
417
|
(ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)
|
|
@@ -379,6 +440,10 @@ interface PropInfo {
|
|
|
379
440
|
inverse?: boolean;
|
|
380
441
|
/** Unsupported type — reported as a warning and skipped. */
|
|
381
442
|
skip?: string;
|
|
443
|
+
/** This column is the resource's `access.ownerField`. */
|
|
444
|
+
owner?: boolean;
|
|
445
|
+
/** This column is the resource's `access.groupField`. */
|
|
446
|
+
group?: boolean;
|
|
382
447
|
}
|
|
383
448
|
|
|
384
449
|
/** A column-shaped member: an interface property or a class instance field. */
|
|
@@ -449,13 +514,19 @@ function analyzeProperty(
|
|
|
449
514
|
if (ts.isUnionTypeNode(typeNode)) {
|
|
450
515
|
const arms = typeNode.types.filter((t) => {
|
|
451
516
|
const isNullish =
|
|
452
|
-
(ts.isLiteralTypeNode(t) &&
|
|
517
|
+
(ts.isLiteralTypeNode(t) &&
|
|
518
|
+
t.literal.kind === ts.SyntaxKind.NullKeyword) ||
|
|
453
519
|
t.kind === ts.SyntaxKind.UndefinedKeyword;
|
|
454
520
|
if (isNullish) nullable = true;
|
|
455
521
|
return !isNullish;
|
|
456
522
|
});
|
|
457
523
|
if (arms.length > 1 && arms.every(isStringish)) {
|
|
458
|
-
return scalarProp(
|
|
524
|
+
return scalarProp(
|
|
525
|
+
name,
|
|
526
|
+
"string",
|
|
527
|
+
configFromTags(jsdocTags(member)),
|
|
528
|
+
nullable,
|
|
529
|
+
);
|
|
459
530
|
}
|
|
460
531
|
if (arms.length !== 1) {
|
|
461
532
|
return { name, nullable, skip: "unsupported union type — skipped" };
|
|
@@ -497,7 +568,8 @@ function analyzeProperty(
|
|
|
497
568
|
function initializerDefault(
|
|
498
569
|
member: EntityProp,
|
|
499
570
|
): string | number | boolean | undefined {
|
|
500
|
-
if (!ts.isPropertyDeclaration(member) || !member.initializer)
|
|
571
|
+
if (!ts.isPropertyDeclaration(member) || !member.initializer)
|
|
572
|
+
return undefined;
|
|
501
573
|
const init = member.initializer;
|
|
502
574
|
if (ts.isStringLiteralLike(init)) return init.text;
|
|
503
575
|
if (ts.isNumericLiteral(init)) return Number(init.text);
|
|
@@ -558,7 +630,10 @@ function scalarProp(
|
|
|
558
630
|
if (config.primaryKey) field.primaryKey = true;
|
|
559
631
|
if (nullable) field.null = true;
|
|
560
632
|
if (config.default !== undefined) field.default = config.default;
|
|
561
|
-
|
|
633
|
+
const info: PropInfo = { name, nullable, field };
|
|
634
|
+
if (config.owner) info.owner = true;
|
|
635
|
+
if (config.group) info.group = true;
|
|
636
|
+
return info;
|
|
562
637
|
}
|
|
563
638
|
|
|
564
639
|
// -- column configuration -------------------------------------------------------
|
|
@@ -572,6 +647,10 @@ interface ColumnConfig {
|
|
|
572
647
|
unique?: boolean;
|
|
573
648
|
primaryKey?: boolean;
|
|
574
649
|
default?: unknown;
|
|
650
|
+
/** `string & Owner` / `@owner` — this column records the owning subject. */
|
|
651
|
+
owner?: boolean;
|
|
652
|
+
/** `string & Group` / `@group` — this column records the owning group. */
|
|
653
|
+
group?: boolean;
|
|
575
654
|
}
|
|
576
655
|
|
|
577
656
|
/**
|
|
@@ -601,6 +680,8 @@ function configFromType(
|
|
|
601
680
|
if (prop("datetime")) config.datetime = true;
|
|
602
681
|
if (prop("unique")) config.unique = true;
|
|
603
682
|
if (prop("primaryKey")) config.primaryKey = true;
|
|
683
|
+
if (prop("owner")) config.owner = true;
|
|
684
|
+
if (prop("group")) config.group = true;
|
|
604
685
|
const maxLength = prop("maxLength");
|
|
605
686
|
if (maxLength?.isNumberLiteral()) config.maxLength = maxLength.value;
|
|
606
687
|
const dflt = prop("default");
|
|
@@ -617,6 +698,8 @@ function configFromTags(tags: Map<string, string>): ColumnConfig {
|
|
|
617
698
|
if (tags.has("datetime")) config.datetime = true;
|
|
618
699
|
if (tags.has("unique")) config.unique = true;
|
|
619
700
|
if (tags.has("primarykey")) config.primaryKey = true;
|
|
701
|
+
if (tags.has("owner")) config.owner = true;
|
|
702
|
+
if (tags.has("group")) config.group = true;
|
|
620
703
|
const maxLength = Number(tags.get("maxlength"));
|
|
621
704
|
if (Number.isFinite(maxLength) && maxLength > 0) config.maxLength = maxLength;
|
|
622
705
|
const dflt = tags.get("default");
|
|
@@ -650,7 +733,8 @@ function unwrapParens(node: ts.TypeNode): ts.TypeNode {
|
|
|
650
733
|
/** `string`, a string-literal type, or a template literal type. */
|
|
651
734
|
function isStringish(node: ts.TypeNode): boolean {
|
|
652
735
|
if (node.kind === ts.SyntaxKind.StringKeyword) return true;
|
|
653
|
-
if (ts.isLiteralTypeNode(node) && ts.isStringLiteralLike(node.literal))
|
|
736
|
+
if (ts.isLiteralTypeNode(node) && ts.isStringLiteralLike(node.literal))
|
|
737
|
+
return true;
|
|
654
738
|
return ts.isTemplateLiteralTypeNode(node);
|
|
655
739
|
}
|
|
656
740
|
|
|
@@ -660,7 +744,8 @@ function arrayElement(node: ts.TypeNode): ts.TypeNode | undefined {
|
|
|
660
744
|
if (
|
|
661
745
|
ts.isTypeReferenceNode(node) &&
|
|
662
746
|
ts.isIdentifier(node.typeName) &&
|
|
663
|
-
(node.typeName.text === "Array" ||
|
|
747
|
+
(node.typeName.text === "Array" ||
|
|
748
|
+
node.typeName.text === "ReadonlyArray") &&
|
|
664
749
|
node.typeArguments?.length === 1
|
|
665
750
|
) {
|
|
666
751
|
return node.typeArguments[0];
|
|
@@ -685,7 +770,8 @@ function entityNaming(decl: EntityDecl): EntityNaming {
|
|
|
685
770
|
// so hook usage of generated types resolves to the same resource.
|
|
686
771
|
const stripped = /^I[A-Z]/.test(raw) ? raw.slice(1) : raw;
|
|
687
772
|
const model =
|
|
688
|
-
tags.get("rastackmodel") ||
|
|
773
|
+
tags.get("rastackmodel") ||
|
|
774
|
+
stripped.charAt(0).toLowerCase() + stripped.slice(1);
|
|
689
775
|
const app =
|
|
690
776
|
tags.get("rastackapp") ||
|
|
691
777
|
path
|
|
@@ -695,7 +781,11 @@ function entityNaming(decl: EntityDecl): EntityNaming {
|
|
|
695
781
|
|
|
696
782
|
const naming: EntityNaming = { app, model };
|
|
697
783
|
const search = tags.get("rastacksearch");
|
|
698
|
-
if (search)
|
|
784
|
+
if (search)
|
|
785
|
+
naming.search = search
|
|
786
|
+
.split(",")
|
|
787
|
+
.map((s) => s.trim())
|
|
788
|
+
.filter(Boolean);
|
|
699
789
|
const permission = tags.get("rastackpermission");
|
|
700
790
|
if (
|
|
701
791
|
permission === "authenticatedOrReadOnly" ||
|
|
@@ -707,6 +797,90 @@ function entityNaming(decl: EntityDecl): EntityNaming {
|
|
|
707
797
|
return naming;
|
|
708
798
|
}
|
|
709
799
|
|
|
800
|
+
// -- access policy (auth as types) ---------------------------------------------
|
|
801
|
+
|
|
802
|
+
/** The phantom keys the `rastack/auth` entity markers carry. */
|
|
803
|
+
const MARKER_PROPS = [
|
|
804
|
+
"__rastackPermission",
|
|
805
|
+
"__rastackAdminGroups",
|
|
806
|
+
"__rastackScope",
|
|
807
|
+
] as const;
|
|
808
|
+
|
|
809
|
+
/** Whether a heritage type is an auth marker (policy, not behaviour). */
|
|
810
|
+
function isAccessMarker(type: ts.Type): boolean {
|
|
811
|
+
return MARKER_PROPS.some((p) => type.getProperty(p));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** An entity's access policy, however it was authored. */
|
|
815
|
+
interface EntityPolicy {
|
|
816
|
+
permission?: ResourceModel["permission"];
|
|
817
|
+
adminGroups?: string[];
|
|
818
|
+
scope?: "owner" | "group" | "shared";
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Read the entity's access policy: the auth markers on its heritage clauses
|
|
823
|
+
* (`implements IsAuthenticated, AdminGroups<"staff">` on a class, `extends`
|
|
824
|
+
* on an interface) merged over the JSDoc tag equivalents
|
|
825
|
+
* (`@rastackPermission`, `@rastackAdminGroups`, `@rastackScope`) — the type
|
|
826
|
+
* wins when both are present, like the `DbConfig` brands do for columns.
|
|
827
|
+
*/
|
|
828
|
+
function entityPolicy(
|
|
829
|
+
decl: EntityDecl,
|
|
830
|
+
checker: ts.TypeChecker,
|
|
831
|
+
naming: EntityNaming,
|
|
832
|
+
): EntityPolicy {
|
|
833
|
+
const policy: EntityPolicy = {};
|
|
834
|
+
const tags = jsdocTags(decl);
|
|
835
|
+
|
|
836
|
+
const adminGroupsTag = tags.get("rastackadmingroups");
|
|
837
|
+
if (adminGroupsTag) {
|
|
838
|
+
const groups = adminGroupsTag
|
|
839
|
+
.split(",")
|
|
840
|
+
.map((g) => g.trim())
|
|
841
|
+
.filter(Boolean);
|
|
842
|
+
if (groups.length) policy.adminGroups = groups;
|
|
843
|
+
}
|
|
844
|
+
const scopeTag = tags.get("rastackscope");
|
|
845
|
+
if (scopeTag === "owner" || scopeTag === "group" || scopeTag === "shared") {
|
|
846
|
+
policy.scope = scopeTag;
|
|
847
|
+
}
|
|
848
|
+
policy.permission = naming.permission; // @rastackPermission, parsed with naming
|
|
849
|
+
|
|
850
|
+
for (const clause of decl.heritageClauses ?? []) {
|
|
851
|
+
for (const typeNode of clause.types) {
|
|
852
|
+
const type = checker.getTypeFromTypeNode(typeNode);
|
|
853
|
+
const literals = (prop: string): string[] => {
|
|
854
|
+
const symbol = type.getProperty(prop);
|
|
855
|
+
if (!symbol) return [];
|
|
856
|
+
const carrier = checker
|
|
857
|
+
.getTypeOfSymbolAtLocation(symbol, decl)
|
|
858
|
+
.getNonNullableType();
|
|
859
|
+
const arms = carrier.isUnion() ? carrier.types : [carrier];
|
|
860
|
+
return arms
|
|
861
|
+
.filter((t): t is ts.StringLiteralType => t.isStringLiteral())
|
|
862
|
+
.map((t) => t.value);
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const [permission] = literals("__rastackPermission");
|
|
866
|
+
if (
|
|
867
|
+
permission === "authenticated" ||
|
|
868
|
+
permission === "authenticatedOrReadOnly" ||
|
|
869
|
+
permission === "public"
|
|
870
|
+
) {
|
|
871
|
+
policy.permission = permission;
|
|
872
|
+
}
|
|
873
|
+
const adminGroups = literals("__rastackAdminGroups");
|
|
874
|
+
if (adminGroups.length) policy.adminGroups = adminGroups.sort();
|
|
875
|
+
const [scope] = literals("__rastackScope");
|
|
876
|
+
if (scope === "owner" || scope === "group" || scope === "shared") {
|
|
877
|
+
policy.scope = scope;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return policy;
|
|
882
|
+
}
|
|
883
|
+
|
|
710
884
|
/** All JSDoc tags on a node, keyed by lower-cased tag name → comment text. */
|
|
711
885
|
function jsdocTags(node: ts.Node): Map<string, string> {
|
|
712
886
|
const tags = new Map<string, string>();
|
package/src/compile/model.ts
CHANGED
|
@@ -5,12 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export type ScalarType =
|
|
8
|
-
| "
|
|
9
|
-
| "int"
|
|
10
|
-
| "float"
|
|
11
|
-
| "bool"
|
|
12
|
-
| "datetime"
|
|
13
|
-
| "uuid";
|
|
8
|
+
"string" | "int" | "float" | "bool" | "datetime" | "uuid";
|
|
14
9
|
|
|
15
10
|
export type FieldType = ScalarType | "fk";
|
|
16
11
|
|
|
@@ -60,12 +55,16 @@ export interface AdminModel {
|
|
|
60
55
|
/**
|
|
61
56
|
* Row-level access control, enforced by `rastack-api-core` on every surface.
|
|
62
57
|
* `ownerField` names the field stamped with the authenticated subject;
|
|
63
|
-
* `
|
|
64
|
-
*
|
|
58
|
+
* `groupField` names the field holding the owning group (rows visible to its
|
|
59
|
+
* members). `scope: "owner"` places the table under a per-identity warehouse
|
|
60
|
+
* prefix (`tenants/{sub}/…`) so object-storage IAM can fence data per user;
|
|
61
|
+
* `scope: "group"` partitions per group (`groups/{name}/…`) so each group's
|
|
62
|
+
* records are physically stored apart.
|
|
65
63
|
*/
|
|
66
64
|
export interface AccessModel {
|
|
67
65
|
ownerField?: string;
|
|
68
|
-
|
|
66
|
+
groupField?: string;
|
|
67
|
+
scope?: "owner" | "group" | "shared";
|
|
69
68
|
adminGroups?: string[];
|
|
70
69
|
}
|
|
71
70
|
|
package/src/compile/openapi.ts
CHANGED
|
@@ -257,16 +257,24 @@ function syncStamp(operation: any, resource: ResourceModel, verb: string): any {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
* Stamp `x-rastack-access` on an operation
|
|
261
|
-
* downstream tooling
|
|
262
|
-
* the
|
|
260
|
+
* Stamp `x-rastack-permission` / `x-rastack-access` on an operation so
|
|
261
|
+
* downstream tooling knows the resource's full access policy: the permission
|
|
262
|
+
* gate, the owner field (row-level security), tenant scope (the local cache
|
|
263
|
+
* partitions per identity), and the RLS-bypassing admin groups — everything
|
|
264
|
+
* `rastack/auth`'s `policyFromOpenApi` needs to mirror the server's rules in
|
|
265
|
+
* the UI.
|
|
263
266
|
*/
|
|
264
267
|
function accessStamp(operation: any, resource: ResourceModel): void {
|
|
268
|
+
if (resource.permission) {
|
|
269
|
+
operation["x-rastack-permission"] = resource.permission;
|
|
270
|
+
}
|
|
265
271
|
const access = resource.access;
|
|
266
272
|
if (!access) return;
|
|
267
273
|
const stamp: any = {};
|
|
268
274
|
if (access.ownerField) stamp.ownerField = access.ownerField;
|
|
275
|
+
if (access.groupField) stamp.groupField = access.groupField;
|
|
269
276
|
if (access.scope) stamp.scope = access.scope;
|
|
277
|
+
if (access.adminGroups?.length) stamp.adminGroups = access.adminGroups;
|
|
270
278
|
if (Object.keys(stamp).length) operation["x-rastack-access"] = stamp;
|
|
271
279
|
}
|
|
272
280
|
|
package/src/compile/program.ts
CHANGED
|
@@ -4,7 +4,8 @@ import * as ts from "typescript";
|
|
|
4
4
|
/**
|
|
5
5
|
* Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
|
|
6
6
|
* definition files. The authoring subpaths (`rastack/define` for the DSL,
|
|
7
|
-
* `rastack/
|
|
7
|
+
* `rastack/db` for the `DbConfig` column brands, `rastack/auth` for the auth
|
|
8
|
+
* markers, `rastack/types` as the alias for both) are mapped to the
|
|
8
9
|
* in-tree/installed sources via `paths` so both the shipped package name and
|
|
9
10
|
* the local demo/tests resolve without extra linking.
|
|
10
11
|
*/
|
|
@@ -26,7 +27,11 @@ export function createProgram(fileNames: string[]): ts.Program {
|
|
|
26
27
|
paths: {
|
|
27
28
|
"rastack/define": [defineDir],
|
|
28
29
|
"rastack/define/*": [defineDir + "/*"],
|
|
29
|
-
|
|
30
|
+
// `rastack/db` = column brands, `rastack/auth` = auth markers + the
|
|
31
|
+
// Owner/Group brands; `rastack/types` is the compatibility alias for both.
|
|
32
|
+
"rastack/db": [path.join(defineDir, "db")],
|
|
33
|
+
"rastack/auth": [path.join(defineDir, "auth")],
|
|
34
|
+
"rastack/types": [path.join(defineDir, "types")],
|
|
30
35
|
},
|
|
31
36
|
};
|
|
32
37
|
|
|
@@ -41,5 +46,7 @@ export function resourceSourceFiles(
|
|
|
41
46
|
const wanted = new Set(fileNames.map((f) => path.resolve(f)));
|
|
42
47
|
return program
|
|
43
48
|
.getSourceFiles()
|
|
44
|
-
.filter(
|
|
49
|
+
.filter(
|
|
50
|
+
(sf) => !sf.isDeclarationFile && wanted.has(path.resolve(sf.fileName)),
|
|
51
|
+
);
|
|
45
52
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
|
|
27
|
+
export * from "./markers";
|
|
28
|
+
export type { Owner, Group } from "./db";
|
package/src/define/db.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;
|
|
@@ -48,11 +48,33 @@ export interface Unique extends DbConfig<{ unique: true }> {}
|
|
|
48
48
|
export interface PrimaryKey extends DbConfig<{ primaryKey: true }> {}
|
|
49
49
|
|
|
50
50
|
/** Maximum length for string columns: `string & MaxLength<120>`. */
|
|
51
|
-
export interface MaxLength<N extends number> extends DbConfig<{
|
|
51
|
+
export interface MaxLength<N extends number> extends DbConfig<{
|
|
52
|
+
maxLength: N;
|
|
53
|
+
}> {}
|
|
52
54
|
|
|
53
55
|
/** Server-side default value: `string & Default<"scheduled">`, `number & Default<0>`. */
|
|
54
|
-
export interface Default<V extends string | number | boolean>
|
|
55
|
-
|
|
56
|
+
export interface Default<V extends string | number | boolean> extends DbConfig<{
|
|
57
|
+
default: V;
|
|
58
|
+
}> {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Marks the column that records the owning identity (the verified token
|
|
62
|
+
* `sub`): `owner: string & Owner`. Compiles to `access.ownerField` — the row
|
|
63
|
+
* is stamped from the token on create, immutable after, and rows are only
|
|
64
|
+
* visible to their owner (plus `AdminGroups` members). Add the `OwnerScoped`
|
|
65
|
+
* entity marker to *also* partition the table physically per identity.
|
|
66
|
+
*/
|
|
67
|
+
export interface Owner extends DbConfig<{ owner: true }> {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Marks the column that records the owning group (a verified `cognito:groups`
|
|
71
|
+
* value): `team: string & Group`. Compiles to `access.groupField` — rows are
|
|
72
|
+
* only visible to members of their group, and the dataset is **physically
|
|
73
|
+
* partitioned per group** by default (`groups/{name}/…` on object storage),
|
|
74
|
+
* so each group's records live in their own files. Add the `SharedStorage`
|
|
75
|
+
* entity marker to keep group rows in one shared table instead.
|
|
76
|
+
*/
|
|
77
|
+
export interface Group extends DbConfig<{ group: true }> {}
|
|
56
78
|
|
|
57
79
|
/** An integer column — a bare `number` property maps to `float`. */
|
|
58
80
|
export type Int = number & DbConfig<{ int: true }>;
|