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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev-server schema compiler — the way around "classes need a compile
|
|
3
|
+
* step".
|
|
4
|
+
*
|
|
5
|
+
* Class/interface entities have no runtime shape (TypeScript erases their
|
|
6
|
+
* types), so *something* must run the `ts.Program` + `TypeChecker` to read
|
|
7
|
+
* fields, `DbConfig` brands and FK types off them. The insight is that it
|
|
8
|
+
* doesn't have to be a command the developer runs: this module packages the
|
|
9
|
+
* same `analyzeProject`/`compile` pass the CLI uses so it can live **inside
|
|
10
|
+
* the dev server that is already running** — a bundler plugin
|
|
11
|
+
* (`rastackPlugin`, Vite/Rollup-compatible) or a bare filesystem watcher
|
|
12
|
+
* (`startRastackWatcher`, for anything else). The schema recompiles on every
|
|
13
|
+
* save, the app imports it as the `virtual:rastack-manifest` module, and
|
|
14
|
+
* `rastack-env.d.ts` is rewritten so string-key row types stay fresh — the
|
|
15
|
+
* developer never runs `rastack compile`.
|
|
16
|
+
*
|
|
17
|
+
* Design mirrors the rest of `tools/src`: the decisions live here as pure
|
|
18
|
+
* (or thin-IO) functions, tested in `tools/test/plugin.spec.ts`; the plugin
|
|
19
|
+
* object in `./index.ts` is a shell over {@link createSchemaState}.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "fs";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
import { analyzeProject, buildManifest, compile } from "../compile";
|
|
25
|
+
import type { EntityDiagnostic, Manifest } from "../compile";
|
|
26
|
+
|
|
27
|
+
/** The module id apps import the live manifest from. */
|
|
28
|
+
export const VIRTUAL_MANIFEST_ID = "virtual:rastack-manifest";
|
|
29
|
+
|
|
30
|
+
/** The resolved (bundler-internal) id — Rollup convention: `\0` prefix. */
|
|
31
|
+
export const RESOLVED_MANIFEST_ID = "\0" + VIRTUAL_MANIFEST_ID;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The auto-registration module the plugin injects into `index.html`: it sets
|
|
35
|
+
* `globalThis.__RASTACK_MANIFEST__` before the app boots, which is what lets
|
|
36
|
+
* `<RAStackProvider mode="local">` (and bare `useData(Airport)`) run with
|
|
37
|
+
* **no manifest wiring in app code at all**. Kept un-prefixed (no `\0`) so
|
|
38
|
+
* its dev-server URL is a plain `/@id/…`.
|
|
39
|
+
*/
|
|
40
|
+
export const VIRTUAL_REGISTER_ID = "virtual:rastack-manifest/register";
|
|
41
|
+
|
|
42
|
+
/** The key the register module assigns (mirrored in `rastack/hooks`). */
|
|
43
|
+
export const GLOBAL_MANIFEST_KEY = "__RASTACK_MANIFEST__";
|
|
44
|
+
|
|
45
|
+
export interface RastackPluginOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Directory (or single file) scanned for entities — classes, interfaces
|
|
48
|
+
* and `resource()` calls alike, recursively (co-located definitions work).
|
|
49
|
+
* Default `"resources"`; pass `"src"` (or `"."`) for co-located entities.
|
|
50
|
+
*/
|
|
51
|
+
resourcesDir?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Where the artifacts land: `schema.rastack.json`, `openapi.json`,
|
|
54
|
+
* `rastack-env.d.ts` (the string-key type registry) and
|
|
55
|
+
* `rastack-plugin.d.ts` (types for the virtual module). Default
|
|
56
|
+
* `".rastack"` — include it in `tsconfig.json` once.
|
|
57
|
+
*/
|
|
58
|
+
outDir?: string;
|
|
59
|
+
/** Skip writing the artifacts to disk (virtual module only). Default false. */
|
|
60
|
+
emitFiles?: boolean;
|
|
61
|
+
/** Where compile problems are reported. Default `console.error`. */
|
|
62
|
+
onError?: (error: unknown) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ResolvedPluginOptions {
|
|
66
|
+
resourcesDir: string;
|
|
67
|
+
outDir: string;
|
|
68
|
+
emitFiles: boolean;
|
|
69
|
+
onError: (error: unknown) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveOptions(
|
|
73
|
+
options: RastackPluginOptions = {},
|
|
74
|
+
root: string = process.cwd(),
|
|
75
|
+
): ResolvedPluginOptions {
|
|
76
|
+
return {
|
|
77
|
+
resourcesDir: path.resolve(root, options.resourcesDir ?? "resources"),
|
|
78
|
+
outDir: path.resolve(root, options.outDir ?? ".rastack"),
|
|
79
|
+
emitFiles: options.emitFiles !== false,
|
|
80
|
+
onError:
|
|
81
|
+
options.onError ??
|
|
82
|
+
((error) =>
|
|
83
|
+
console.error(
|
|
84
|
+
`[rastack] schema compile failed — serving the last good manifest\n` +
|
|
85
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
86
|
+
)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a changed file should trigger a schema recompile: a `.ts`/`.tsx`
|
|
92
|
+
* source under the scanned directory that is not itself an emitted artifact.
|
|
93
|
+
*/
|
|
94
|
+
export function isEntitySource(
|
|
95
|
+
file: string,
|
|
96
|
+
options: Pick<ResolvedPluginOptions, "resourcesDir" | "outDir">,
|
|
97
|
+
): boolean {
|
|
98
|
+
const resolved = path.resolve(file);
|
|
99
|
+
if (!/\.tsx?$/.test(resolved) || resolved.endsWith(".d.ts")) return false;
|
|
100
|
+
if (within(resolved, options.outDir)) return false;
|
|
101
|
+
return (
|
|
102
|
+
within(resolved, options.resourcesDir) || resolved === options.resourcesDir
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function within(file: string, dir: string): boolean {
|
|
107
|
+
const rel = path.relative(dir, file);
|
|
108
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The virtual module's source — the manifest as a plain ES module. */
|
|
112
|
+
export function manifestModuleCode(manifest: Manifest): string {
|
|
113
|
+
const json = JSON.stringify(manifest);
|
|
114
|
+
return (
|
|
115
|
+
`// virtual:rastack-manifest — compiled by the rastack dev-server plugin.\n` +
|
|
116
|
+
`const manifest = ${json};\n` +
|
|
117
|
+
`export default manifest;\n` +
|
|
118
|
+
`export { manifest };\n`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The register module's source: import the live manifest, publish it on the
|
|
124
|
+
* global the runtime falls back to. A separate module (rather than a side
|
|
125
|
+
* effect of the manifest module) so explicit `virtual:rastack-manifest`
|
|
126
|
+
* importers stay side-effect-free.
|
|
127
|
+
*/
|
|
128
|
+
export function registerModuleCode(): string {
|
|
129
|
+
return (
|
|
130
|
+
`// ${VIRTUAL_REGISTER_ID} — auto-injected by the rastack plugin.\n` +
|
|
131
|
+
`import manifest from ${JSON.stringify(VIRTUAL_MANIFEST_ID)};\n` +
|
|
132
|
+
`globalThis.${GLOBAL_MANIFEST_KEY} = manifest;\n` +
|
|
133
|
+
`export default manifest;\n`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Types for the virtual module, written once beside `rastack-env.d.ts`. */
|
|
138
|
+
export function virtualModuleDts(): string {
|
|
139
|
+
return (
|
|
140
|
+
"// Generated by the rastack plugin — do not edit.\n" +
|
|
141
|
+
`declare module "${VIRTUAL_MANIFEST_ID}" {\n` +
|
|
142
|
+
' const manifest: import("rastack/define").Manifest;\n' +
|
|
143
|
+
" export default manifest;\n" +
|
|
144
|
+
" export { manifest };\n" +
|
|
145
|
+
"}\n"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface SchemaState {
|
|
150
|
+
/**
|
|
151
|
+
* The virtual module's current source. Compiles (lazily) when stale; on a
|
|
152
|
+
* compile error reports via `onError` and keeps serving the last good
|
|
153
|
+
* manifest — a half-typed entity must not blank the running app.
|
|
154
|
+
*/
|
|
155
|
+
code(): string;
|
|
156
|
+
/** The current manifest (same laziness/fallback as {@link code}). */
|
|
157
|
+
manifest(): Manifest;
|
|
158
|
+
/** Mark the schema stale — the next `code()`/`manifest()` recompiles. */
|
|
159
|
+
invalidate(): void;
|
|
160
|
+
/** The error from the most recent failed compile, if it is still failing. */
|
|
161
|
+
error(): unknown;
|
|
162
|
+
/** Non-error diagnostics from the last successful compile. */
|
|
163
|
+
diagnostics(): EntityDiagnostic[];
|
|
164
|
+
options: ResolvedPluginOptions;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The plugin's whole brain: lazy compile, invalidation, keep-last-good.
|
|
169
|
+
* Both the bundler plugin and the bare watcher drive this one object.
|
|
170
|
+
*/
|
|
171
|
+
export function createSchemaState(
|
|
172
|
+
options: RastackPluginOptions = {},
|
|
173
|
+
root?: string,
|
|
174
|
+
): SchemaState {
|
|
175
|
+
const resolved = resolveOptions(options, root);
|
|
176
|
+
let current: { manifest: Manifest; diagnostics: EntityDiagnostic[] } | null =
|
|
177
|
+
null;
|
|
178
|
+
let stale = true;
|
|
179
|
+
let lastError: unknown = undefined;
|
|
180
|
+
|
|
181
|
+
const recompile = (): void => {
|
|
182
|
+
try {
|
|
183
|
+
const result = runCompile(resolved);
|
|
184
|
+
current = { manifest: result.manifest, diagnostics: result.diagnostics };
|
|
185
|
+
lastError = undefined;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
lastError = error;
|
|
188
|
+
resolved.onError(error);
|
|
189
|
+
if (!current) throw error; // no last-good manifest to fall back to
|
|
190
|
+
} finally {
|
|
191
|
+
stale = false;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const ensure = (): { manifest: Manifest; diagnostics: EntityDiagnostic[] } => {
|
|
196
|
+
if (stale || !current) recompile();
|
|
197
|
+
return current!;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
code: () => manifestModuleCode(ensure().manifest),
|
|
202
|
+
manifest: () => ensure().manifest,
|
|
203
|
+
invalidate: () => {
|
|
204
|
+
stale = true;
|
|
205
|
+
},
|
|
206
|
+
error: () => lastError,
|
|
207
|
+
diagnostics: () => ensure().diagnostics,
|
|
208
|
+
options: resolved,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One compile pass: the same artifacts `rastack compile` writes (manifest,
|
|
214
|
+
* OpenAPI, `rastack-env.d.ts`), plus the virtual module's type declaration.
|
|
215
|
+
* With `emitFiles: false` it only analyses (nothing touches disk).
|
|
216
|
+
*/
|
|
217
|
+
export function runCompile(options: ResolvedPluginOptions): {
|
|
218
|
+
manifest: Manifest;
|
|
219
|
+
diagnostics: EntityDiagnostic[];
|
|
220
|
+
} {
|
|
221
|
+
if (!options.emitFiles) {
|
|
222
|
+
// compile() always writes to disk; the analyse-only path applies the
|
|
223
|
+
// same error gate without touching the filesystem.
|
|
224
|
+
const { resources, diagnostics } = analyzeProject(options.resourcesDir);
|
|
225
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
226
|
+
if (errors.length) throw new Error(errors.map((e) => e.message).join("\n"));
|
|
227
|
+
return { manifest: buildManifest(resources), diagnostics };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result = compile(options.resourcesDir, options.outDir);
|
|
231
|
+
const dtsPath = path.join(options.outDir, "rastack-plugin.d.ts");
|
|
232
|
+
if (!fs.existsSync(dtsPath)) {
|
|
233
|
+
fs.writeFileSync(dtsPath, virtualModuleDts());
|
|
234
|
+
}
|
|
235
|
+
return { manifest: result.manifest, diagnostics: result.diagnostics };
|
|
236
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/plugin` — the schema compiles itself inside your dev server.
|
|
3
|
+
*
|
|
4
|
+
* Class and interface entities are pure types, so the manifest has to be
|
|
5
|
+
* derived by the TypeScript checker — but not by a command you run. Add the
|
|
6
|
+
* plugin to the bundler you already use and the whole `rastack compile`
|
|
7
|
+
* pass disappears into the dev loop:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // vite.config.ts
|
|
11
|
+
* import { rastackPlugin } from "rastack/plugin";
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* plugins: [rastackPlugin({ resourcesDir: "src/entities" })],
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* // app — the manifest is a live import, recompiled on every save
|
|
19
|
+
* import manifest from "virtual:rastack-manifest";
|
|
20
|
+
* <RAStackProvider mode="local" manifest={manifest}>…</RAStackProvider>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* What it does on your behalf, on every relevant file save:
|
|
24
|
+
* - recompiles the entity graph (classes, interfaces, `resource()` calls);
|
|
25
|
+
* - serves the manifest as the `virtual:rastack-manifest` module and
|
|
26
|
+
* hot-invalidates it;
|
|
27
|
+
* - rewrites `.rastack/rastack-env.d.ts` (string-key row types) and
|
|
28
|
+
* `schema.rastack.json`/`openapi.json`, so the committed artifacts and the
|
|
29
|
+
* registry never go stale;
|
|
30
|
+
* - on a broken in-progress edit, reports the error and keeps serving the
|
|
31
|
+
* last good manifest.
|
|
32
|
+
*
|
|
33
|
+
* The plugin object is Vite/Rollup-compatible (Vite-specific hooks are
|
|
34
|
+
* ignored by plain Rollup). For dev servers with other plugin systems
|
|
35
|
+
* (Next/webpack, Metro/Expo), `startRastackWatcher` does the same
|
|
36
|
+
* recompile-on-save against the filesystem — call it once from your config —
|
|
37
|
+
* and `rastack dev` remains the zero-config path.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import * as fs from "fs";
|
|
41
|
+
import * as path from "path";
|
|
42
|
+
import {
|
|
43
|
+
createSchemaState,
|
|
44
|
+
isEntitySource,
|
|
45
|
+
registerModuleCode,
|
|
46
|
+
RESOLVED_MANIFEST_ID,
|
|
47
|
+
VIRTUAL_MANIFEST_ID,
|
|
48
|
+
VIRTUAL_REGISTER_ID,
|
|
49
|
+
type RastackPluginOptions,
|
|
50
|
+
type SchemaState,
|
|
51
|
+
} from "./core";
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
createSchemaState,
|
|
55
|
+
GLOBAL_MANIFEST_KEY,
|
|
56
|
+
isEntitySource,
|
|
57
|
+
manifestModuleCode,
|
|
58
|
+
registerModuleCode,
|
|
59
|
+
resolveOptions,
|
|
60
|
+
RESOLVED_MANIFEST_ID,
|
|
61
|
+
runCompile,
|
|
62
|
+
VIRTUAL_MANIFEST_ID,
|
|
63
|
+
VIRTUAL_REGISTER_ID,
|
|
64
|
+
virtualModuleDts,
|
|
65
|
+
} from "./core";
|
|
66
|
+
export type {
|
|
67
|
+
RastackPluginOptions,
|
|
68
|
+
ResolvedPluginOptions,
|
|
69
|
+
SchemaState,
|
|
70
|
+
} from "./core";
|
|
71
|
+
|
|
72
|
+
/** One HTML tag descriptor in Vite's `transformIndexHtml` shape. */
|
|
73
|
+
export interface HtmlTagDescriptor {
|
|
74
|
+
tag: string;
|
|
75
|
+
attrs?: Record<string, string>;
|
|
76
|
+
children?: string;
|
|
77
|
+
injectTo?: "head" | "head-prepend" | "body" | "body-prepend";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The minimal bundler-plugin surface we emit (Vite superset of Rollup). */
|
|
81
|
+
export interface RastackBundlerPlugin {
|
|
82
|
+
name: string;
|
|
83
|
+
buildStart(): void;
|
|
84
|
+
resolveId(id: string): string | undefined;
|
|
85
|
+
load(id: string): string | undefined;
|
|
86
|
+
watchChange(id: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Vite-only: auto-register the schema. Injects a module script that sets
|
|
89
|
+
* `globalThis.__RASTACK_MANIFEST__` before the app boots, so the provider
|
|
90
|
+
* and hooks need no `manifest` wiring at all. Object form with
|
|
91
|
+
* `order: "pre"` — the tag must be injected *before* Vite scans the HTML
|
|
92
|
+
* entry, or the build ships the virtual import verbatim.
|
|
93
|
+
*/
|
|
94
|
+
transformIndexHtml: {
|
|
95
|
+
order: "pre";
|
|
96
|
+
handler(html: string, ctx?: { server?: unknown }): HtmlTagDescriptor[];
|
|
97
|
+
};
|
|
98
|
+
/** Vite-only: recompile + hot-invalidate the virtual manifest module. */
|
|
99
|
+
handleHotUpdate(ctx: {
|
|
100
|
+
file: string;
|
|
101
|
+
server: {
|
|
102
|
+
moduleGraph: {
|
|
103
|
+
getModuleById(id: string): unknown;
|
|
104
|
+
invalidateModule(mod: any): void;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
modules: unknown[];
|
|
108
|
+
}): unknown[] | void;
|
|
109
|
+
/** The live schema state — exposed for tests and advanced callers. */
|
|
110
|
+
api: SchemaState;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The bundler plugin. Works as-is in Vite and Rollup; the returned object is
|
|
115
|
+
* plain data + closures, so any bundler with resolve/load/watch hooks can
|
|
116
|
+
* adapt it.
|
|
117
|
+
*/
|
|
118
|
+
export function rastackPlugin(
|
|
119
|
+
options: RastackPluginOptions = {},
|
|
120
|
+
): RastackBundlerPlugin {
|
|
121
|
+
const state = createSchemaState(options);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name: "rastack",
|
|
125
|
+
|
|
126
|
+
buildStart() {
|
|
127
|
+
// Compile eagerly so authoring errors surface at server start, and the
|
|
128
|
+
// artifacts (env.d.ts, schema.rastack.json) exist before first load.
|
|
129
|
+
state.manifest();
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
resolveId(id: string) {
|
|
133
|
+
if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;
|
|
134
|
+
if (id === VIRTUAL_REGISTER_ID) return VIRTUAL_REGISTER_ID;
|
|
135
|
+
return undefined;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
load(id: string) {
|
|
139
|
+
if (id === RESOLVED_MANIFEST_ID) return state.code();
|
|
140
|
+
if (id === VIRTUAL_REGISTER_ID) return registerModuleCode();
|
|
141
|
+
return undefined;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
watchChange(id: string) {
|
|
145
|
+
if (isEntitySource(id, state.options)) state.invalidate();
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
handleHotUpdate(ctx) {
|
|
149
|
+
if (!isEntitySource(ctx.file, state.options)) return;
|
|
150
|
+
state.invalidate();
|
|
151
|
+
const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
152
|
+
if (mod) {
|
|
153
|
+
ctx.server.moduleGraph.invalidateModule(mod);
|
|
154
|
+
return [...ctx.modules, mod];
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
transformIndexHtml: {
|
|
159
|
+
order: "pre",
|
|
160
|
+
handler(_html: string, ctx?: { server?: unknown }) {
|
|
161
|
+
// Dev serves resolved ids at /@id/<id>; build gets an inline module
|
|
162
|
+
// script that (because this runs `pre`, before Vite scans the HTML
|
|
163
|
+
// entry) is bundled, its import resolving back through
|
|
164
|
+
// resolveId/load above.
|
|
165
|
+
const attrs: Record<string, string> = { type: "module" };
|
|
166
|
+
const tag: HtmlTagDescriptor = ctx?.server
|
|
167
|
+
? {
|
|
168
|
+
tag: "script",
|
|
169
|
+
attrs: { ...attrs, src: `/@id/${VIRTUAL_REGISTER_ID}` },
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
tag: "script",
|
|
173
|
+
attrs,
|
|
174
|
+
children: `import ${JSON.stringify(VIRTUAL_REGISTER_ID)};`,
|
|
175
|
+
};
|
|
176
|
+
return [{ ...tag, injectTo: "head-prepend" }];
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
api: state,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Alias so `import { rastack } from "rastack/plugin"` reads naturally. */
|
|
185
|
+
export const rastack = rastackPlugin;
|
|
186
|
+
|
|
187
|
+
export interface RastackWatcher {
|
|
188
|
+
/** Stop watching. */
|
|
189
|
+
close(): void;
|
|
190
|
+
/** The live schema state (same object the recompiles flow through). */
|
|
191
|
+
state: SchemaState;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The plugin for everything that isn't Rollup-shaped: watch the resources
|
|
196
|
+
* directory and rewrite the artifacts (`schema.rastack.json`,
|
|
197
|
+
* `openapi.json`, `rastack-env.d.ts`) on every save. Call once from any
|
|
198
|
+
* Node-side config (next.config.js, metro.config.js, a dev script):
|
|
199
|
+
*
|
|
200
|
+
* ```js
|
|
201
|
+
* // next.config.js
|
|
202
|
+
* const { startRastackWatcher } = require("rastack/plugin");
|
|
203
|
+
* if (process.env.NODE_ENV === "development") startRastackWatcher();
|
|
204
|
+
* ```
|
|
205
|
+
*
|
|
206
|
+
* The app then imports `.rastack/schema.rastack.json` directly (a plain JSON
|
|
207
|
+
* import every bundler hot-reloads) instead of the virtual module.
|
|
208
|
+
*/
|
|
209
|
+
export function startRastackWatcher(
|
|
210
|
+
options: RastackPluginOptions = {},
|
|
211
|
+
): RastackWatcher {
|
|
212
|
+
const state = createSchemaState(options);
|
|
213
|
+
state.manifest(); // initial compile (writes the artifacts)
|
|
214
|
+
|
|
215
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
216
|
+
const watcher = fs.watch(
|
|
217
|
+
state.options.resourcesDir,
|
|
218
|
+
{ recursive: true },
|
|
219
|
+
(_event, filename) => {
|
|
220
|
+
// fs.watch reports paths relative to the watched directory.
|
|
221
|
+
const file = filename
|
|
222
|
+
? path.join(state.options.resourcesDir, String(filename))
|
|
223
|
+
: undefined;
|
|
224
|
+
if (!file || !isEntitySource(file, state.options)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
state.invalidate();
|
|
228
|
+
// Debounce bursts (editors write twice, git checkouts touch many files).
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
timer = setTimeout(() => {
|
|
231
|
+
state.manifest();
|
|
232
|
+
}, 100);
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
close: () => {
|
|
238
|
+
if (timer) clearTimeout(timer);
|
|
239
|
+
watcher.close();
|
|
240
|
+
},
|
|
241
|
+
state,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `rastack/auth` core — the client-side mirror of the access
|
|
3
|
+
* rules `rastack-api-core` enforces (see `rust/rastack-api-core/tests/access.rs`
|
|
4
|
+
* for the server side of the same matrix). The semantics here must track the
|
|
5
|
+
* Rust `check_permission` / `enforced_owner` exactly: `can()` answers "would
|
|
6
|
+
* the server accept this request?", so the components can limit the paths they
|
|
7
|
+
* expose instead of surfacing 401s.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
anonymousAuth,
|
|
12
|
+
authFromClaims,
|
|
13
|
+
bypassesRls,
|
|
14
|
+
can,
|
|
15
|
+
canActOnRow,
|
|
16
|
+
isAdmin,
|
|
17
|
+
ownsRow,
|
|
18
|
+
policyFromManifest,
|
|
19
|
+
policyFromOpenApi,
|
|
20
|
+
trustedAuth,
|
|
21
|
+
userAuth,
|
|
22
|
+
type ResourcePolicy,
|
|
23
|
+
} from "../auth/core";
|
|
24
|
+
|
|
25
|
+
const rls: ResourcePolicy = {
|
|
26
|
+
permission: "authenticated",
|
|
27
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("rastack/auth — the permission gate (can)", () => {
|
|
31
|
+
it("defaults to authenticated — secure by default, like the server", () => {
|
|
32
|
+
expect(can(anonymousAuth(), {}, "list")).toBe(false);
|
|
33
|
+
expect(can(anonymousAuth(), undefined, "create")).toBe(false);
|
|
34
|
+
expect(can(userAuth("u1"), {}, "list")).toBe(true);
|
|
35
|
+
expect(can(userAuth("u1"), undefined, "delete")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("public admits everyone, for every action", () => {
|
|
39
|
+
const policy: ResourcePolicy = { permission: "public" };
|
|
40
|
+
for (const action of [
|
|
41
|
+
"list",
|
|
42
|
+
"retrieve",
|
|
43
|
+
"create",
|
|
44
|
+
"update",
|
|
45
|
+
"delete",
|
|
46
|
+
] as const) {
|
|
47
|
+
expect(can(anonymousAuth(), policy, action)).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("authenticatedOrReadOnly admits anonymous reads but not writes", () => {
|
|
52
|
+
const policy: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
|
|
53
|
+
expect(can(anonymousAuth(), policy, "list")).toBe(true);
|
|
54
|
+
expect(can(anonymousAuth(), policy, "retrieve")).toBe(true);
|
|
55
|
+
expect(can(anonymousAuth(), policy, "create")).toBe(false);
|
|
56
|
+
expect(can(anonymousAuth(), policy, "update")).toBe(false);
|
|
57
|
+
expect(can(anonymousAuth(), policy, "delete")).toBe(false);
|
|
58
|
+
expect(can(userAuth("u1"), policy, "delete")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("row-secured resources need a subject (the server answers 401 without one)", () => {
|
|
62
|
+
// Authenticated but subject-less — e.g. a machine token without `sub`.
|
|
63
|
+
const noSub = { authenticated: true, groups: [] };
|
|
64
|
+
expect(can(noSub, rls, "list")).toBe(false);
|
|
65
|
+
expect(can(userAuth("u1"), rls, "list")).toBe(true);
|
|
66
|
+
// A configured admin group bypasses row-level security.
|
|
67
|
+
expect(can({ ...noSub, groups: ["staff"] }, rls, "list")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("trusted contexts (dev without auth) pass everything", () => {
|
|
71
|
+
expect(can(trustedAuth(), rls, "delete")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("a missing identity gates nothing — the server stays the enforcer", () => {
|
|
75
|
+
expect(can(undefined, rls, "delete")).toBe(true);
|
|
76
|
+
expect(can(null, { permission: "authenticated" }, "create")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("rastack/auth — row-level security helpers", () => {
|
|
81
|
+
const mine = { id: 1, owner: "u1" };
|
|
82
|
+
const theirs = { id: 2, owner: "u2" };
|
|
83
|
+
|
|
84
|
+
it("owners own their rows; others don't", () => {
|
|
85
|
+
expect(ownsRow(userAuth("u1"), rls, mine)).toBe(true);
|
|
86
|
+
expect(ownsRow(userAuth("u1"), rls, theirs)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("admin groups and trusted contexts bypass the owner filter", () => {
|
|
90
|
+
expect(bypassesRls(userAuth("u1", ["staff"]), rls)).toBe(true);
|
|
91
|
+
expect(ownsRow(userAuth("u1", ["staff"]), rls, theirs)).toBe(true);
|
|
92
|
+
expect(ownsRow(trustedAuth(), rls, theirs)).toBe(true);
|
|
93
|
+
expect(bypassesRls(userAuth("u1", ["other"]), rls)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("unsecured resources are owned by everyone", () => {
|
|
97
|
+
expect(ownsRow(userAuth("u1"), {}, theirs)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("canActOnRow combines the gate and ownership", () => {
|
|
101
|
+
expect(canActOnRow(userAuth("u1"), rls, "update", mine)).toBe(true);
|
|
102
|
+
expect(canActOnRow(userAuth("u1"), rls, "update", theirs)).toBe(false);
|
|
103
|
+
expect(canActOnRow(anonymousAuth(), rls, "update", mine)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("rastack/auth — group-owned datasets", () => {
|
|
108
|
+
// A group dataset: rows belong to a team, physically partitioned per group.
|
|
109
|
+
const grouped: ResourcePolicy = {
|
|
110
|
+
permission: "authenticated",
|
|
111
|
+
access: { groupField: "team", scope: "group", adminGroups: ["staff"] },
|
|
112
|
+
};
|
|
113
|
+
const blueRow = { id: 1, team: "blue" };
|
|
114
|
+
|
|
115
|
+
it("members read; identities in no group can read (empty) but never write", () => {
|
|
116
|
+
const dana = userAuth("dana", ["blue"]);
|
|
117
|
+
const loner = userAuth("lonnie", []);
|
|
118
|
+
expect(can(dana, grouped, "list")).toBe(true);
|
|
119
|
+
expect(can(dana, grouped, "create")).toBe(true);
|
|
120
|
+
// Reads outside any group just come back empty — but a write can never
|
|
121
|
+
// succeed, so the UI shouldn't offer it.
|
|
122
|
+
expect(can(loner, grouped, "list")).toBe(true);
|
|
123
|
+
expect(can(loner, grouped, "create")).toBe(false);
|
|
124
|
+
expect(can(loner, grouped, "delete")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("row membership mirrors the server's group guard", () => {
|
|
128
|
+
expect(ownsRow(userAuth("dana", ["blue"]), grouped, blueRow)).toBe(true);
|
|
129
|
+
expect(ownsRow(userAuth("erin", ["green"]), grouped, blueRow)).toBe(false);
|
|
130
|
+
expect(
|
|
131
|
+
canActOnRow(userAuth("erin", ["green"]), grouped, "update", blueRow),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
// adminGroups members act across groups.
|
|
134
|
+
expect(ownsRow(userAuth("sam", ["staff"]), grouped, blueRow)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("owner + group guards both apply when a policy carries both", () => {
|
|
138
|
+
const both: ResourcePolicy = {
|
|
139
|
+
access: { ownerField: "owner", groupField: "team" },
|
|
140
|
+
};
|
|
141
|
+
const row = { id: 1, owner: "dana", team: "blue" };
|
|
142
|
+
expect(ownsRow(userAuth("dana", ["blue"]), both, row)).toBe(true);
|
|
143
|
+
expect(ownsRow(userAuth("dana", ["green"]), both, row)).toBe(false);
|
|
144
|
+
expect(ownsRow(userAuth("erin", ["blue"]), both, row)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("isAdmin is the scope-driven admin role", () => {
|
|
148
|
+
expect(isAdmin(userAuth("sam", ["staff"]), grouped)).toBe(true);
|
|
149
|
+
expect(isAdmin(userAuth("dana", ["blue"]), grouped)).toBe(false);
|
|
150
|
+
expect(isAdmin(trustedAuth(), grouped)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("rastack/auth — building an identity", () => {
|
|
155
|
+
it("authFromClaims reads sub and cognito:groups from a decoded token", () => {
|
|
156
|
+
const auth = authFromClaims({
|
|
157
|
+
sub: "u1",
|
|
158
|
+
"cognito:groups": ["staff", 42],
|
|
159
|
+
email: "x@y.z",
|
|
160
|
+
});
|
|
161
|
+
expect(auth).toEqual({ authenticated: true, sub: "u1", groups: ["staff"] });
|
|
162
|
+
expect(authFromClaims({}).authenticated).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("rastack/auth — policy lookup", () => {
|
|
167
|
+
const manifest = {
|
|
168
|
+
resources: [
|
|
169
|
+
{ app: "airports", model: "airport", fields: [] },
|
|
170
|
+
{
|
|
171
|
+
app: "airports",
|
|
172
|
+
model: "itinerary",
|
|
173
|
+
fields: [],
|
|
174
|
+
permission: "authenticated",
|
|
175
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
it("policyFromManifest extracts permission + access", () => {
|
|
181
|
+
expect(policyFromManifest(manifest, "airports.itinerary")).toEqual(rls);
|
|
182
|
+
expect(
|
|
183
|
+
policyFromManifest(manifest, { app: "airports", model: "airport" }),
|
|
184
|
+
).toEqual({});
|
|
185
|
+
expect(policyFromManifest(manifest, "airports.missing")).toBeUndefined();
|
|
186
|
+
// Accepts the JSON string form the provider may hold.
|
|
187
|
+
expect(
|
|
188
|
+
policyFromManifest(JSON.stringify(manifest), "airports.itinerary"),
|
|
189
|
+
).toEqual(rls);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("policyFromOpenApi reads the x-rastack-permission / x-rastack-access stamps", () => {
|
|
193
|
+
const doc = {
|
|
194
|
+
paths: {
|
|
195
|
+
"/api/airports/v1/itinerary/": {
|
|
196
|
+
get: {
|
|
197
|
+
operationId: "listItinerary",
|
|
198
|
+
"x-rastack-permission": "authenticated",
|
|
199
|
+
"x-rastack-access": rls.access,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
expect(policyFromOpenApi(doc, "airports.itinerary")).toEqual(rls);
|
|
205
|
+
expect(policyFromOpenApi(doc, "airports.airport")).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
});
|