rastack 0.0.51 → 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 +2 -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.js +7 -2
- 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 +19 -2
- package/dist/define/manifest.js +10 -1
- 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/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/provider/index.ts +1 -5
- package/provider/provider.tsx +42 -11
- package/provider/types.ts +14 -2
- package/provider/warehouse.ts +4 -3
- package/provider/wasm.ts +3 -1
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +15 -3
- 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 +20 -2
- package/src/define/manifest.ts +10 -1
- package/src/define/markers.ts +83 -0
- package/src/define/types.ts +11 -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/types.ts +7 -2
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/dist/compile/analyze.js
CHANGED
|
@@ -85,7 +85,9 @@ function analyzeDsl(program, fileNames) {
|
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
// Deterministic order: app, then model.
|
|
88
|
-
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));
|
|
89
91
|
types.sort((a, b) => a.key.localeCompare(b.key));
|
|
90
92
|
return { resources, types };
|
|
91
93
|
}
|
|
@@ -116,7 +118,10 @@ function analyzeResourceCall(call, checker) {
|
|
|
116
118
|
const app = stringLiteralArg(call.arguments[0]);
|
|
117
119
|
const model = stringLiteralArg(call.arguments[1]);
|
|
118
120
|
const fieldsArg = call.arguments[2];
|
|
119
|
-
if (!app ||
|
|
121
|
+
if (!app ||
|
|
122
|
+
!model ||
|
|
123
|
+
!fieldsArg ||
|
|
124
|
+
!ts.isObjectLiteralExpression(fieldsArg)) {
|
|
120
125
|
return undefined;
|
|
121
126
|
}
|
|
122
127
|
const fields = [];
|
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;
|
package/dist/define/index.d.ts
CHANGED
|
@@ -124,17 +124,34 @@ export interface SyncOptions {
|
|
|
124
124
|
* - `ownerField` — the field that records the owning identity (the verified
|
|
125
125
|
* token `sub`). Rows are only listed/read/written by their owner; the field
|
|
126
126
|
* is stamped from the token on create and immutable on update.
|
|
127
|
+
* - `groupField` — the field that records the owning *group* (a verified
|
|
128
|
+
* `cognito:groups` value). Rows are only visible to members of their group,
|
|
129
|
+
* and the dataset defaults to per-group physical partitioning.
|
|
127
130
|
* - `scope: "owner"` — additionally *physically* partitions the resource's
|
|
128
131
|
* Iceberg table per identity (`tenants/{sub}/{app}.db/{model}/…`), so
|
|
129
132
|
* object-storage credentials (e.g. Cognito Identity Pool temp creds) can be
|
|
130
133
|
* IAM-restricted to `tenants/${"${cognito-identity.amazonaws.com:sub}"}/*`.
|
|
134
|
+
* `scope: "group"` partitions per group instead (`groups/{name}/…`);
|
|
135
|
+
* `scope: "shared"` keeps one table.
|
|
131
136
|
* - `adminGroups` — token groups (`cognito:groups`) that bypass row filters.
|
|
137
|
+
*
|
|
138
|
+
* Class/interface entities express the same policy in the type system — the
|
|
139
|
+
* `Owner`/`Group` field brands and `IsAuthenticated`/`AdminGroups<…>`/
|
|
140
|
+
* `OwnerScoped` markers from `rastack/auth`.
|
|
132
141
|
*/
|
|
133
142
|
export interface AccessOptions {
|
|
134
143
|
ownerField?: string;
|
|
135
|
-
|
|
144
|
+
groupField?: string;
|
|
145
|
+
scope?: "owner" | "group" | "shared";
|
|
136
146
|
adminGroups?: string[];
|
|
137
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* The resource-level permission gate — which paths an identity may hit at all.
|
|
150
|
+
* Enforced by `rastack-api-core` before any row is read; mirrored client-side
|
|
151
|
+
* by `rastack/auth`'s `can()` so components hide what the server would reject.
|
|
152
|
+
* Unset means `authenticated` — secure by default.
|
|
153
|
+
*/
|
|
154
|
+
export type Permission = "authenticatedOrReadOnly" | "authenticated" | "public";
|
|
138
155
|
/**
|
|
139
156
|
* One edge of a resource's state machine. `from` is the state (or states) the
|
|
140
157
|
* transition may fire from, `to` the state it enters, and `set` the effect —
|
|
@@ -193,7 +210,7 @@ export interface ResourceOptions {
|
|
|
193
210
|
/** Local-first replication config. */
|
|
194
211
|
sync?: SyncOptions;
|
|
195
212
|
/** Access policy (secure by default). */
|
|
196
|
-
permission?:
|
|
213
|
+
permission?: Permission;
|
|
197
214
|
/** Row-level security + physical tenant partitioning. */
|
|
198
215
|
access?: AccessOptions;
|
|
199
216
|
/** Declarative state machine over one field, enforced on every write surface. */
|
package/dist/define/manifest.js
CHANGED
|
@@ -230,9 +230,18 @@ function normaliseAccess(access) {
|
|
|
230
230
|
const model = {};
|
|
231
231
|
if (typeof access.ownerField === "string")
|
|
232
232
|
model.ownerField = access.ownerField;
|
|
233
|
-
if (
|
|
233
|
+
if (typeof access.groupField === "string")
|
|
234
|
+
model.groupField = access.groupField;
|
|
235
|
+
if (access.scope === "owner" ||
|
|
236
|
+
access.scope === "group" ||
|
|
237
|
+
access.scope === "shared") {
|
|
234
238
|
model.scope = access.scope;
|
|
235
239
|
}
|
|
240
|
+
else if (model.groupField) {
|
|
241
|
+
// Group-owned datasets are physically partitioned per group by default —
|
|
242
|
+
// that is their point. `scope: "shared"` opts out explicitly.
|
|
243
|
+
model.scope = "group";
|
|
244
|
+
}
|
|
236
245
|
if (Array.isArray(access.adminGroups)) {
|
|
237
246
|
model.adminGroups = access.adminGroups.filter((g) => typeof g === "string");
|
|
238
247
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity-level authorization markers — auth as *TypeScript types*.
|
|
3
|
+
*
|
|
4
|
+
* Class and interface entities have no options bag, so their access policy is
|
|
5
|
+
* expressed the same way their columns are: in the type system. Attach a
|
|
6
|
+
* marker with `implements` (classes) or `extends` (interfaces) and the
|
|
7
|
+
* compiler reads the policy straight off the heritage clause:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { AdminGroups, IsAuthenticated, Owner, Group } from "rastack/auth";
|
|
11
|
+
*
|
|
12
|
+
* // Per-user rows: RLS on `owner`, staff sees everything.
|
|
13
|
+
* export class Itinerary implements IsAuthenticated, AdminGroups<"staff"> {
|
|
14
|
+
* owner!: string & Owner;
|
|
15
|
+
* notes!: string;
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* // Group-owned dataset: rows belong to a team and are *physically stored*
|
|
19
|
+
* // under that team's own storage prefix (groups/{team}/…).
|
|
20
|
+
* export class Report implements IsAuthenticated, AdminGroups<"staff"> {
|
|
21
|
+
* team!: string & Group;
|
|
22
|
+
* title!: string;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The markers are phantom types: every property is optional and never exists
|
|
27
|
+
* at runtime, so `implements` needs no members and instances stay plain data.
|
|
28
|
+
* Each marker family uses its own phantom key, so markers combine freely —
|
|
29
|
+
* while two *permission* markers on one entity conflict in the type checker,
|
|
30
|
+
* which is exactly right.
|
|
31
|
+
*
|
|
32
|
+
* JSDoc equivalents exist for every marker (`@rastackPermission`,
|
|
33
|
+
* `@rastackAdminGroups staff, ops`, `@rastackScope owner|group|shared`, and
|
|
34
|
+
* field-level `@owner` / `@group`), mirroring the column-constraint tags.
|
|
35
|
+
*/
|
|
36
|
+
/** The permission gate carried in the type system. */
|
|
37
|
+
export interface PermissionOf<P extends string> {
|
|
38
|
+
readonly __rastackPermission?: P;
|
|
39
|
+
}
|
|
40
|
+
/** Every path requires a verified identity (this is also the default). */
|
|
41
|
+
export interface IsAuthenticated extends PermissionOf<"authenticated"> {
|
|
42
|
+
}
|
|
43
|
+
/** Anonymous callers may read (list/retrieve); writes require identity. */
|
|
44
|
+
export interface IsAuthenticatedOrReadOnly extends PermissionOf<"authenticatedOrReadOnly"> {
|
|
45
|
+
}
|
|
46
|
+
/** No gate — every caller may read and write. Opt-in, never the default. */
|
|
47
|
+
export interface IsPublic extends PermissionOf<"public"> {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Token groups (`cognito:groups`) that bypass row-level security on this
|
|
51
|
+
* entity — the "is admin" role, driven from scopes: `implements
|
|
52
|
+
* AdminGroups<"staff" | "ops">`. Members see and act on every row.
|
|
53
|
+
*/
|
|
54
|
+
export interface AdminGroups<G extends string> {
|
|
55
|
+
readonly __rastackAdminGroups?: G;
|
|
56
|
+
}
|
|
57
|
+
/** Storage scope carried in the type system. */
|
|
58
|
+
export interface ScopeOf<S extends string> {
|
|
59
|
+
readonly __rastackScope?: S;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Physically partition the entity's table per identity
|
|
63
|
+
* (`tenants/{sub}/…`) — the prefix object-storage IAM can fence per user.
|
|
64
|
+
* Combine with an `Owner` field brand.
|
|
65
|
+
*/
|
|
66
|
+
export interface OwnerScoped extends ScopeOf<"owner"> {
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Physically partition the entity's table per group (`groups/{name}/…`).
|
|
70
|
+
* This is the default whenever a `Group` field brand is present — declare it
|
|
71
|
+
* only for emphasis or with a JSDoc-configured group field.
|
|
72
|
+
*/
|
|
73
|
+
export interface GroupScoped extends ScopeOf<"group"> {
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Keep rows in one shared table (row-level filters still apply). Use to opt a
|
|
77
|
+
* `Group`-branded entity out of the default per-group physical partitioning.
|
|
78
|
+
*/
|
|
79
|
+
export interface SharedStorage extends ScopeOf<"shared"> {
|
|
80
|
+
}
|