@renoxium/sdk 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +178 -3
- package/dist/authz.d.ts +31 -0
- package/dist/authz.js +39 -0
- package/dist/crypto.d.ts +6 -0
- package/dist/crypto.js +26 -0
- package/dist/ctx.d.ts +486 -0
- package/dist/ctx.js +13 -0
- package/dist/db.d.ts +36 -0
- package/dist/db.js +3 -0
- package/dist/drizzle.d.ts +8 -0
- package/dist/drizzle.js +42 -0
- package/dist/errors.d.ts +261 -0
- package/dist/errors.js +232 -0
- package/dist/fakes.d.ts +35 -0
- package/dist/fakes.js +144 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +15 -0
- package/dist/jobs.d.ts +16 -0
- package/dist/jobs.js +72 -0
- package/dist/kysely.d.ts +11 -0
- package/dist/kysely.js +126 -0
- package/dist/modules.d.ts +72 -0
- package/dist/modules.js +30 -0
- package/dist/pages.d.ts +53 -0
- package/dist/pages.js +14 -0
- package/dist/realtime.d.ts +15 -0
- package/dist/realtime.js +40 -0
- package/dist/redirect.d.ts +19 -0
- package/dist/redirect.js +57 -0
- package/dist/test.d.ts +169 -0
- package/dist/test.js +381 -0
- package/dist/tools.d.ts +24 -0
- package/dist/tools.js +105 -0
- package/dist/totp.d.ts +40 -0
- package/dist/totp.js +111 -0
- package/dist/validate.d.ts +76 -0
- package/dist/validate.js +105 -0
- package/dist/webauthn.d.ts +63 -0
- package/dist/webauthn.js +283 -0
- package/package.json +85 -6
package/dist/test.d.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/** What installing or upgrading a module reported (`renox check` and the migration plan). */
|
|
2
|
+
export interface ModuleChange {
|
|
3
|
+
check: {
|
|
4
|
+
errors: {
|
|
5
|
+
message: string;
|
|
6
|
+
file?: string;
|
|
7
|
+
}[];
|
|
8
|
+
findings: {
|
|
9
|
+
rule: string;
|
|
10
|
+
severity: string;
|
|
11
|
+
message: string;
|
|
12
|
+
file: string;
|
|
13
|
+
}[];
|
|
14
|
+
};
|
|
15
|
+
migrate: {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
module?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
modules: {
|
|
20
|
+
module: string;
|
|
21
|
+
applied: string[];
|
|
22
|
+
skipped: string[];
|
|
23
|
+
}[];
|
|
24
|
+
} | null;
|
|
25
|
+
project: string;
|
|
26
|
+
}
|
|
27
|
+
/** A response with its body read. */
|
|
28
|
+
export interface Reply {
|
|
29
|
+
status: number;
|
|
30
|
+
headers: Headers;
|
|
31
|
+
html: string;
|
|
32
|
+
json<T = unknown>(): T;
|
|
33
|
+
}
|
|
34
|
+
export interface RequestInitLite {
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
body?: string | Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
/** A service proxy: `service("notes").add("x")` resolves with the service's value or throws its error. */
|
|
39
|
+
export type ServiceProxy = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
|
40
|
+
/** The error a service threw, as the broker reported it. */
|
|
41
|
+
export declare class ServiceError extends Error {
|
|
42
|
+
readonly code: string | null;
|
|
43
|
+
readonly details: unknown;
|
|
44
|
+
constructor(message: string, code: string | null, details: unknown);
|
|
45
|
+
}
|
|
46
|
+
export interface TestApp {
|
|
47
|
+
/** Any request; a JSON object `body` is sent as JSON. */
|
|
48
|
+
request(method: string, path: string, init?: RequestInitLite): Promise<Reply>;
|
|
49
|
+
/** GET a page as this app's current user (its own cookie jar: session and CSRF). */
|
|
50
|
+
page(path: string, options?: {
|
|
51
|
+
user?: string;
|
|
52
|
+
}): Promise<Reply>;
|
|
53
|
+
/** POST a page action (`?/<name>`) with the form and the session's CSRF token. */
|
|
54
|
+
action(path: string, name: string, form: Record<string, string>, options?: {
|
|
55
|
+
user?: string;
|
|
56
|
+
}): Promise<Reply>;
|
|
57
|
+
/** Call an installed module's service through the broker. */
|
|
58
|
+
service(module: string): ServiceProxy;
|
|
59
|
+
/** Read-only SQL on the tenant file, for assertions. */
|
|
60
|
+
db: {
|
|
61
|
+
query<T = Record<string, unknown>>(sql: string, params?: (string | number | null)[]): T[];
|
|
62
|
+
};
|
|
63
|
+
/** The tenant file itself (to age a row in an expiry test, open it with bun:sqlite). */
|
|
64
|
+
dbFile: string;
|
|
65
|
+
/** The same app as another user: a separate session (cookie jar). */
|
|
66
|
+
as(user: string): TestApp;
|
|
67
|
+
/** Read a cookie of this user's jar, or plant one (an attacker's token, a forged state). */
|
|
68
|
+
cookie(name: string, value?: string): string | undefined;
|
|
69
|
+
/** Open a WebSocket route as this user (its cookies, the app's origin); read messages with `next`. */
|
|
70
|
+
ws(path: string, options?: {
|
|
71
|
+
headers?: Record<string, string>;
|
|
72
|
+
}): Promise<WsStream>;
|
|
73
|
+
/** Open an SSE route (`GET`, `Accept: text/event-stream`) as this user; read its events with `next`. */
|
|
74
|
+
sse(path: string, options?: {
|
|
75
|
+
headers?: Record<string, string>;
|
|
76
|
+
}): Promise<SseStream>;
|
|
77
|
+
/** Tenant log lines so far. */
|
|
78
|
+
logs(): Record<string, unknown>[];
|
|
79
|
+
/** Meter records so far (flushed about every second). */
|
|
80
|
+
meters(): Record<string, unknown>[];
|
|
81
|
+
stop(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
/** One SSE event as received (ops.md section 18, "On the wire"). */
|
|
84
|
+
export interface SseEvent {
|
|
85
|
+
id: string | null;
|
|
86
|
+
event: string | null;
|
|
87
|
+
data: string;
|
|
88
|
+
}
|
|
89
|
+
/** An open SSE connection of a test. */
|
|
90
|
+
export interface SseStream {
|
|
91
|
+
status: number;
|
|
92
|
+
headers: Headers;
|
|
93
|
+
/** The next event, or null when none arrives within `ms` (default 2000) or the stream ended. */
|
|
94
|
+
next(ms?: number): Promise<SseEvent | null>;
|
|
95
|
+
/** Events received so far and not yet returned by `next`. */
|
|
96
|
+
pending(): SseEvent[];
|
|
97
|
+
close(): void;
|
|
98
|
+
}
|
|
99
|
+
/** An open WebSocket of a test. */
|
|
100
|
+
export interface WsStream {
|
|
101
|
+
/** The next text message, or null when none arrives within `ms` (default 2000) or the socket closed. */
|
|
102
|
+
next(ms?: number): Promise<string | null>;
|
|
103
|
+
send(text: string): void;
|
|
104
|
+
/** Resolves with the close code (1008 for a refused subscribe, 1011 for a throwing `open`). */
|
|
105
|
+
closed: Promise<number>;
|
|
106
|
+
close(): void;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A fresh worker and tenant file for this test file (stopped after it):
|
|
110
|
+
* migrations applied, then `seeds/*.sql` and `renox test --seed` files.
|
|
111
|
+
*/
|
|
112
|
+
export declare function app(options?: {
|
|
113
|
+
project?: string;
|
|
114
|
+
}): Promise<TestApp>;
|
|
115
|
+
/**
|
|
116
|
+
* The page's HTML without what changes per request (request id, deployment,
|
|
117
|
+
* nonces, CSRF tokens, session ids), matched against the snapshot named by
|
|
118
|
+
* `role`. Returns the normalized HTML.
|
|
119
|
+
*/
|
|
120
|
+
export declare function snapshot(html: string, options: {
|
|
121
|
+
role: string;
|
|
122
|
+
}): string;
|
|
123
|
+
/** One policy test case (`policies/tests/*.json`, #283): evaluated by the engine the cell builds. */
|
|
124
|
+
export interface PolicyCase {
|
|
125
|
+
principal: {
|
|
126
|
+
type: string;
|
|
127
|
+
id: string;
|
|
128
|
+
roles?: string[];
|
|
129
|
+
attrs?: Record<string, unknown>;
|
|
130
|
+
};
|
|
131
|
+
action: string;
|
|
132
|
+
/** `type` is an entity type of the app (`"Doc"`) or a module's (`"auth:Account"`), with its declared attributes. */
|
|
133
|
+
resource?: {
|
|
134
|
+
type: string;
|
|
135
|
+
id?: string;
|
|
136
|
+
[attr: string]: unknown;
|
|
137
|
+
};
|
|
138
|
+
context?: Record<string, unknown>;
|
|
139
|
+
expected: "allow" | "deny";
|
|
140
|
+
description?: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Reads `policies/tests/*.json` (each an array of cases, or `{ cases: [...] }`)
|
|
144
|
+
* and registers one test per case that evaluates it with the engine the
|
|
145
|
+
* cell builds (`renox-worker check-policies`, #283) and expects its
|
|
146
|
+
* `expected` decision. A file that is not in this format fails. `renox
|
|
147
|
+
* test` also runs the project's cases before the test files.
|
|
148
|
+
*/
|
|
149
|
+
export declare function policyCases(dir?: string): PolicyCase[];
|
|
150
|
+
/** `policyCases` of the current project (`policies/tests/*.json`). */
|
|
151
|
+
export declare const policies: () => PolicyCase[];
|
|
152
|
+
/** A module change on a scratch copy of the app: install, then upgrade with the returned handle. */
|
|
153
|
+
export interface ModuleHandle extends ModuleChange {
|
|
154
|
+
/** Re-install from `source`; `version` defaults to `^<version of its module.toml>`. */
|
|
155
|
+
upgrade(source: string, version?: string): Promise<ModuleHandle>;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `installModule("notes", "path:fixtures/notes@1")`: `renox add` + `renox
|
|
159
|
+
* build` + `renox migrate` on a scratch copy of the app and a scratch
|
|
160
|
+
* tenant file. `upgrade(source)` does it again on the same copy and file:
|
|
161
|
+
* what `renox check` reports then (drift, broken contracts) is the upgrade
|
|
162
|
+
* test.
|
|
163
|
+
*/
|
|
164
|
+
export declare function installModule(name: string, source: string, options?: {
|
|
165
|
+
project?: string;
|
|
166
|
+
version?: string;
|
|
167
|
+
}): Promise<ModuleHandle>;
|
|
168
|
+
/** Alias of `ModuleHandle.upgrade` for symmetry: `upgradeModule(installed, "path:…@2")`. */
|
|
169
|
+
export declare const upgradeModule: (installed: ModuleHandle, source: string, version?: string) => Promise<ModuleHandle>;
|
package/dist/test.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// `@renoxium/sdk/test` (#238; docs/12-developer-experience.md, "renox test"):
|
|
2
|
+
// helpers for `tests/*.test.ts` of an app or a module, run by `renox test`
|
|
3
|
+
// on the same prebuilt renox-worker as `renox dev`, one temporary tenant
|
|
4
|
+
// file per test file.
|
|
5
|
+
//
|
|
6
|
+
// import { app, snapshot } from "@renoxium/sdk/test";
|
|
7
|
+
// const t = await app(); // fresh worker + tenant file for this file
|
|
8
|
+
// await t.service("notes").add("hello"); // through the broker (a test-only route)
|
|
9
|
+
// const { html } = await t.as("bob").page("/notes/");
|
|
10
|
+
// snapshot(html, { role: "admin" });
|
|
11
|
+
//
|
|
12
|
+
// Bun-only (bun:test, bun:sqlite): not part of the runtime-neutral SDK
|
|
13
|
+
// surface, and never loaded by tenant code in production.
|
|
14
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
15
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
16
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
17
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return path;
|
|
21
|
+
};
|
|
22
|
+
import { Database } from "bun:sqlite";
|
|
23
|
+
import { afterAll, expect, test } from "bun:test";
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
async function harness() {
|
|
27
|
+
const path = process.env.RENOX_TEST_HARNESS ?? join(import.meta.dir, "../../cli/src/lib/test-harness.ts");
|
|
28
|
+
if (!existsSync(path))
|
|
29
|
+
throw new Error("@renoxium/sdk/test: run the tests with `renox test` (RENOX_TEST_HARNESS is not set)");
|
|
30
|
+
return (await import(__rewriteRelativeImportExtension(path)));
|
|
31
|
+
}
|
|
32
|
+
/** The spec `renox test` prepared, or one built now for the current project (`bun test` with RENOX_WORKER_PATH). */
|
|
33
|
+
async function spec(h, project) {
|
|
34
|
+
const file = process.env.RENOX_TEST_SPEC;
|
|
35
|
+
if (file)
|
|
36
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
37
|
+
const worker = process.env.RENOX_WORKER_PATH;
|
|
38
|
+
if (!worker)
|
|
39
|
+
throw new Error("@renoxium/sdk/test: run the tests with `renox test`, or set RENOX_WORKER_PATH");
|
|
40
|
+
return h.prepare(project, [], worker);
|
|
41
|
+
}
|
|
42
|
+
/** The error a service threw, as the broker reported it. */
|
|
43
|
+
export class ServiceError extends Error {
|
|
44
|
+
code;
|
|
45
|
+
details;
|
|
46
|
+
constructor(message, code, details) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.details = details;
|
|
50
|
+
this.name = "ServiceError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Reads `body` as an SSE stream: comments and `retry:` are skipped. */
|
|
54
|
+
function sseStream(res, abort) {
|
|
55
|
+
const queue = [];
|
|
56
|
+
const waiters = [];
|
|
57
|
+
let ended = false;
|
|
58
|
+
const push = (e) => {
|
|
59
|
+
const w = waiters.shift();
|
|
60
|
+
if (w)
|
|
61
|
+
w(e);
|
|
62
|
+
else if (e)
|
|
63
|
+
queue.push(e);
|
|
64
|
+
};
|
|
65
|
+
void (async () => {
|
|
66
|
+
const reader = res.body?.getReader();
|
|
67
|
+
if (!reader)
|
|
68
|
+
return;
|
|
69
|
+
const dec = new TextDecoder();
|
|
70
|
+
let buf = "";
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
const { done, value } = await reader.read();
|
|
74
|
+
if (done)
|
|
75
|
+
break;
|
|
76
|
+
buf += dec.decode(value, { stream: true });
|
|
77
|
+
let cut;
|
|
78
|
+
while ((cut = buf.indexOf("\n\n")) >= 0) {
|
|
79
|
+
const block = buf.slice(0, cut);
|
|
80
|
+
buf = buf.slice(cut + 2);
|
|
81
|
+
let id = null;
|
|
82
|
+
let event = null;
|
|
83
|
+
const data = [];
|
|
84
|
+
for (const line of block.split("\n")) {
|
|
85
|
+
if (line.startsWith(":"))
|
|
86
|
+
continue;
|
|
87
|
+
const i = line.indexOf(":");
|
|
88
|
+
const field = i < 0 ? line : line.slice(0, i);
|
|
89
|
+
const v = i < 0 ? "" : line.slice(i + 1).replace(/^ /u, "");
|
|
90
|
+
if (field === "id")
|
|
91
|
+
id = v;
|
|
92
|
+
else if (field === "event")
|
|
93
|
+
event = v;
|
|
94
|
+
else if (field === "data")
|
|
95
|
+
data.push(v);
|
|
96
|
+
}
|
|
97
|
+
if (data.length || event !== null)
|
|
98
|
+
push({ id, event, data: data.join("\n") });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// aborted
|
|
104
|
+
}
|
|
105
|
+
ended = true;
|
|
106
|
+
while (waiters.length)
|
|
107
|
+
push(null);
|
|
108
|
+
})();
|
|
109
|
+
return {
|
|
110
|
+
status: res.status,
|
|
111
|
+
headers: res.headers,
|
|
112
|
+
next(ms = 2000) {
|
|
113
|
+
const e = queue.shift();
|
|
114
|
+
if (e)
|
|
115
|
+
return Promise.resolve(e);
|
|
116
|
+
if (ended)
|
|
117
|
+
return Promise.resolve(null);
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const w = (x) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
resolve(x);
|
|
122
|
+
};
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
const i = waiters.indexOf(w);
|
|
125
|
+
if (i >= 0)
|
|
126
|
+
waiters.splice(i, 1);
|
|
127
|
+
resolve(null);
|
|
128
|
+
}, ms);
|
|
129
|
+
waiters.push(w);
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
pending: () => queue.splice(0),
|
|
133
|
+
close: () => abort.abort(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function wsStream(socket) {
|
|
137
|
+
const queue = [];
|
|
138
|
+
const waiters = [];
|
|
139
|
+
let ended = false;
|
|
140
|
+
let resolveClosed = () => { };
|
|
141
|
+
const closed = new Promise((r) => (resolveClosed = r));
|
|
142
|
+
socket.addEventListener("message", (e) => {
|
|
143
|
+
const text = typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data);
|
|
144
|
+
const w = waiters.shift();
|
|
145
|
+
if (w)
|
|
146
|
+
w(text);
|
|
147
|
+
else
|
|
148
|
+
queue.push(text);
|
|
149
|
+
});
|
|
150
|
+
socket.addEventListener("close", (e) => {
|
|
151
|
+
ended = true;
|
|
152
|
+
while (waiters.length)
|
|
153
|
+
waiters.shift()(null);
|
|
154
|
+
resolveClosed(e.code);
|
|
155
|
+
});
|
|
156
|
+
const stream = {
|
|
157
|
+
next(ms = 2000) {
|
|
158
|
+
const m = queue.shift();
|
|
159
|
+
if (m !== undefined)
|
|
160
|
+
return Promise.resolve(m);
|
|
161
|
+
if (ended)
|
|
162
|
+
return Promise.resolve(null);
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const w = (x) => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
resolve(x);
|
|
167
|
+
};
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
const i = waiters.indexOf(w);
|
|
170
|
+
if (i >= 0)
|
|
171
|
+
waiters.splice(i, 1);
|
|
172
|
+
resolve(null);
|
|
173
|
+
}, ms);
|
|
174
|
+
waiters.push(w);
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
send: (text) => socket.send(text),
|
|
178
|
+
closed,
|
|
179
|
+
close: () => socket.close(),
|
|
180
|
+
};
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
183
|
+
resolve(stream);
|
|
184
|
+
socket.addEventListener("open", () => resolve(stream));
|
|
185
|
+
socket.addEventListener("close", () => resolve(stream));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function remember(jar, res) {
|
|
189
|
+
for (const c of res.headers.getSetCookie()) {
|
|
190
|
+
const [pair] = c.split(";");
|
|
191
|
+
const eq = (pair ?? "").indexOf("=");
|
|
192
|
+
if (eq > 0)
|
|
193
|
+
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A fresh worker and tenant file for this test file (stopped after it):
|
|
198
|
+
* migrations applied, then `seeds/*.sql` and `renox test --seed` files.
|
|
199
|
+
*/
|
|
200
|
+
export async function app(options = {}) {
|
|
201
|
+
const h = await harness();
|
|
202
|
+
const s = await spec(h, options.project ?? process.env.RENOX_TEST_PROJECT ?? process.cwd());
|
|
203
|
+
const w = await h.startTestWorker(s);
|
|
204
|
+
afterAll(() => w.stop());
|
|
205
|
+
const jars = new Map();
|
|
206
|
+
const jarOf = (user) => {
|
|
207
|
+
let j = jars.get(user);
|
|
208
|
+
if (j === undefined) {
|
|
209
|
+
j = new Map();
|
|
210
|
+
jars.set(user, j);
|
|
211
|
+
}
|
|
212
|
+
return j;
|
|
213
|
+
};
|
|
214
|
+
const sqlite = new Database(w.db, { readonly: true });
|
|
215
|
+
afterAll(() => sqlite.close());
|
|
216
|
+
const make = (user) => {
|
|
217
|
+
const send = async (method, path, init = {}) => {
|
|
218
|
+
const jar = jarOf(user);
|
|
219
|
+
const headers = { host: w.host, accept: "text/html", ...init.headers };
|
|
220
|
+
// An explicit `cookie` header is sent as given (a replayed token); else the user's jar.
|
|
221
|
+
if (jar.size && init.headers?.cookie === undefined)
|
|
222
|
+
headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
223
|
+
let body;
|
|
224
|
+
if (typeof init.body === "string")
|
|
225
|
+
body = init.body;
|
|
226
|
+
else if (init.body !== undefined) {
|
|
227
|
+
body = JSON.stringify(init.body);
|
|
228
|
+
headers["content-type"] ??= "application/json";
|
|
229
|
+
}
|
|
230
|
+
const res = await fetch(`${w.base}${path}`, { method, headers, redirect: "manual", ...(body !== undefined ? { body } : {}) });
|
|
231
|
+
remember(jar, res);
|
|
232
|
+
const text = await res.text();
|
|
233
|
+
return { status: res.status, headers: res.headers, html: text, json: () => JSON.parse(text) };
|
|
234
|
+
};
|
|
235
|
+
const self = {
|
|
236
|
+
request: send,
|
|
237
|
+
async ws(path, o = {}) {
|
|
238
|
+
const jar = jarOf(user);
|
|
239
|
+
const headers = { host: w.host, origin: `https://${w.host}`, ...o.headers };
|
|
240
|
+
if (jar.size && o.headers?.cookie === undefined)
|
|
241
|
+
headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
242
|
+
// Bun's WebSocket takes request headers (the cookie, the Host the cell routes by).
|
|
243
|
+
const socket = new WebSocket(`${w.base.replace(/^http/u, "ws")}${path}`, { headers });
|
|
244
|
+
afterAll(() => socket.close());
|
|
245
|
+
return wsStream(socket);
|
|
246
|
+
},
|
|
247
|
+
async sse(path, o = {}) {
|
|
248
|
+
const jar = jarOf(user);
|
|
249
|
+
const headers = { host: w.host, accept: "text/event-stream", ...o.headers };
|
|
250
|
+
if (jar.size && o.headers?.cookie === undefined)
|
|
251
|
+
headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
252
|
+
const abort = new AbortController();
|
|
253
|
+
const res = await fetch(`${w.base}${path}`, { headers, redirect: "manual", signal: abort.signal });
|
|
254
|
+
afterAll(() => abort.abort());
|
|
255
|
+
return sseStream(res, abort);
|
|
256
|
+
},
|
|
257
|
+
page: (path, o) => (o?.user ? make(o.user) : self).request("GET", path),
|
|
258
|
+
async action(path, name, form, o) {
|
|
259
|
+
const as = o?.user ? make(o.user) : self;
|
|
260
|
+
const jar = jarOf(o?.user ?? user);
|
|
261
|
+
if (!jar.has("__Host-renox_csrf"))
|
|
262
|
+
await as.request("GET", path);
|
|
263
|
+
const token = jar.get("__Host-renox_csrf") ?? "";
|
|
264
|
+
const body = new URLSearchParams({ ...form }).toString();
|
|
265
|
+
return as.request("POST", `${path}?/${name}`, { headers: { "content-type": "application/x-www-form-urlencoded", "x-renox-csrf": token }, body });
|
|
266
|
+
},
|
|
267
|
+
service: (module) => new Proxy({}, {
|
|
268
|
+
get: (_t, service) => async (...args) => {
|
|
269
|
+
const r = await send("POST", h.SERVICE_PATH, { body: { module, service: String(service), args } });
|
|
270
|
+
const out = r.json();
|
|
271
|
+
if (!out.ok)
|
|
272
|
+
throw new ServiceError(out.error?.message ?? "service failed", out.error?.code ?? null, out.error?.details ?? null);
|
|
273
|
+
return out.value;
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
db: { query: (sql, params = []) => sqlite.query(sql).all(...params) },
|
|
277
|
+
dbFile: w.db,
|
|
278
|
+
as: (other) => make(other),
|
|
279
|
+
cookie: (name, value) => {
|
|
280
|
+
const jar = jarOf(user);
|
|
281
|
+
if (value !== undefined)
|
|
282
|
+
jar.set(name, value);
|
|
283
|
+
return jar.get(name);
|
|
284
|
+
},
|
|
285
|
+
logs: () => w.logs(),
|
|
286
|
+
meters: () => w.meters(),
|
|
287
|
+
stop: () => w.stop(),
|
|
288
|
+
};
|
|
289
|
+
return self;
|
|
290
|
+
};
|
|
291
|
+
return make("anonymous");
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The page's HTML without what changes per request (request id, deployment,
|
|
295
|
+
* nonces, CSRF tokens, session ids), matched against the snapshot named by
|
|
296
|
+
* `role`. Returns the normalized HTML.
|
|
297
|
+
*/
|
|
298
|
+
export function snapshot(html, options) {
|
|
299
|
+
const normalized = html
|
|
300
|
+
.replace(/(<meta name="renox-request-id" content=")[^"]*/gu, "$1<request-id>")
|
|
301
|
+
.replace(/(<meta name="renox-deployment" content=")[^"]*/gu, "$1<deployment>")
|
|
302
|
+
.replace(/nonce="[^"]*"/gu, 'nonce="<nonce>"')
|
|
303
|
+
.replace(/(name="_csrf"[^>]*value=")[^"]*/gu, "$1<csrf>")
|
|
304
|
+
.replace(/(value=")[^"]*("[^>]*name="_csrf")/gu, "$1<csrf>$2")
|
|
305
|
+
.replace(/("x-renox-csrf"\s*:\s*")[^"]*/gu, "$1<csrf>")
|
|
306
|
+
.replace(/\b[0-9A-HJKMNP-TV-Z]{26}\b/gu, "<ulid>")
|
|
307
|
+
.replace(/(\?v=)[0-9a-f]{16}/gu, "$1<version>");
|
|
308
|
+
expect(normalized).toMatchSnapshot(`role: ${options.role}`);
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
const isCase = (v) => {
|
|
312
|
+
const c = v;
|
|
313
|
+
return (typeof c === "object" &&
|
|
314
|
+
c !== null &&
|
|
315
|
+
typeof c.action === "string" &&
|
|
316
|
+
typeof c.principal?.type === "string" &&
|
|
317
|
+
typeof c.principal?.id === "string" &&
|
|
318
|
+
(c.resource === undefined || typeof c.resource?.type === "string") &&
|
|
319
|
+
(c.expected === "allow" || c.expected === "deny"));
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Reads `policies/tests/*.json` (each an array of cases, or `{ cases: [...] }`)
|
|
323
|
+
* and registers one test per case that evaluates it with the engine the
|
|
324
|
+
* cell builds (`renox-worker check-policies`, #283) and expects its
|
|
325
|
+
* `expected` decision. A file that is not in this format fails. `renox
|
|
326
|
+
* test` also runs the project's cases before the test files.
|
|
327
|
+
*/
|
|
328
|
+
export function policyCases(dir = join(process.env.RENOX_TEST_PROJECT ?? process.cwd(), "policies", "tests")) {
|
|
329
|
+
if (!existsSync(dir))
|
|
330
|
+
return [];
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
333
|
+
const raw = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
334
|
+
const list = Array.isArray(raw) ? raw : raw.cases;
|
|
335
|
+
if (!Array.isArray(list) || !list.every(isCase))
|
|
336
|
+
throw new Error(`policies/tests/${f}: expected [{ principal, action, resource, expected: "allow" | "deny" }]`);
|
|
337
|
+
for (const c of list)
|
|
338
|
+
out.push(c);
|
|
339
|
+
}
|
|
340
|
+
if (out.length === 0)
|
|
341
|
+
return out;
|
|
342
|
+
// One evaluation for all the cases of `dir`, shared by their tests.
|
|
343
|
+
const project = join(dir, "..", "..");
|
|
344
|
+
let outcomes;
|
|
345
|
+
const evaluated = () => {
|
|
346
|
+
outcomes ??= (async () => {
|
|
347
|
+
const h = await harness();
|
|
348
|
+
return h.evaluatePolicies(await spec(h, process.env.RENOX_TEST_PROJECT ?? project), project);
|
|
349
|
+
})();
|
|
350
|
+
return outcomes;
|
|
351
|
+
};
|
|
352
|
+
out.forEach((c, i) => {
|
|
353
|
+
const who = `${c.principal.type}::${c.principal.id}${c.principal.roles?.length ? ` (${c.principal.roles.join(", ")})` : ""}`;
|
|
354
|
+
test(`policy: ${c.description ?? `${who} ${c.action} ${c.resource?.type ?? ""}`} -> ${c.expected}`, async () => {
|
|
355
|
+
const o = (await evaluated())[i];
|
|
356
|
+
expect(o?.result.allow ? "allow" : "deny").toBe(c.expected);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
/** `policyCases` of the current project (`policies/tests/*.json`). */
|
|
362
|
+
export const policies = () => policyCases();
|
|
363
|
+
/**
|
|
364
|
+
* `installModule("notes", "path:fixtures/notes@1")`: `renox add` + `renox
|
|
365
|
+
* build` + `renox migrate` on a scratch copy of the app and a scratch
|
|
366
|
+
* tenant file. `upgrade(source)` does it again on the same copy and file:
|
|
367
|
+
* what `renox check` reports then (drift, broken contracts) is the upgrade
|
|
368
|
+
* test.
|
|
369
|
+
*/
|
|
370
|
+
export async function installModule(name, source, options = {}) {
|
|
371
|
+
const h = await harness();
|
|
372
|
+
const project = options.project ?? process.env.RENOX_TEST_PROJECT ?? process.cwd();
|
|
373
|
+
const worker = process.env.RENOX_WORKER_PATH ?? (process.env.RENOX_TEST_SPEC ? JSON.parse(readFileSync(process.env.RENOX_TEST_SPEC, "utf8")).worker : undefined);
|
|
374
|
+
const wrap = (r) => ({
|
|
375
|
+
...r,
|
|
376
|
+
upgrade: async (next, version) => wrap(await h.changeModule(project, name, next, { project: r.project, db: r.db }, worker, version)),
|
|
377
|
+
});
|
|
378
|
+
return wrap(await h.changeModule(project, name, source, undefined, worker, options.version));
|
|
379
|
+
}
|
|
380
|
+
/** Alias of `ModuleHandle.upgrade` for symmetry: `upgradeModule(installed, "path:…@2")`. */
|
|
381
|
+
export const upgradeModule = (installed, source, version) => installed.upgrade(source, version);
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Tool, ToolContext, ToolDefinition } from "./ctx.ts";
|
|
2
|
+
import type { StandardSchemaV1 } from "./validate.ts";
|
|
3
|
+
/** The JSON Schema keywords a tool input may use (validate.ts subset, ops.md section 19). */
|
|
4
|
+
export declare const INPUT_KEYWORDS: readonly ["type", "properties", "required", "additionalProperties", "items", "minItems", "maxItems", "enum", "const", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minLength", "maxLength", "pattern", "format", "description", "default", "anyOf", "nullable"];
|
|
5
|
+
/**
|
|
6
|
+
* The JSON Schema of a Standard Schema input: through the Standard JSON
|
|
7
|
+
* Schema interface (`~standard.jsonSchema.input`, zod 4.2+), else a
|
|
8
|
+
* `toJSONSchema()` method, else the schema itself when it is plain JSON
|
|
9
|
+
* Schema. Throws a TypeError when none applies.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toolInputSchema(schema: unknown): Record<string, unknown>;
|
|
12
|
+
/**
|
|
13
|
+
* Declares a tool. Checks `name`, `description`, `action` and that the
|
|
14
|
+
* input is a Standard Schema; returns the definition with `confirm`
|
|
15
|
+
* defaulted to false.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* export default tool({ name: "create_invoice", description: "...", input: z.object({ ... }), action: "invoice.create", async run(ctx) { ... } });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function tool<S extends StandardSchemaV1>(def: Omit<ToolDefinition<StandardSchemaV1.InferOutput<S>>, "input" | "run"> & {
|
|
22
|
+
input: S;
|
|
23
|
+
run(ctx: ToolContext<StandardSchemaV1.InferOutput<S>>): unknown;
|
|
24
|
+
}): Tool<StandardSchemaV1.InferOutput<S>>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// App MCP tools (#284; docs/contracts/ops.md section 19). `tool()` checks a
|
|
2
|
+
// tool's declaration and returns the `Tool` a `functions/tools/*.ts` (or a
|
|
3
|
+
// module's `tools/*.ts`) default-exports. `renox build` imports the module,
|
|
4
|
+
// takes `name`, `description`, `action` and `confirm`, and records
|
|
5
|
+
// `toolInputSchema(tool.input)` as the route entry's `input`: the JSON
|
|
6
|
+
// Schema of the input, restricted to the keywords the platform checks.
|
|
7
|
+
/** `^[a-z][a-z0-9_]{0,63}$` (artifact.md 1.3, tool routes). */
|
|
8
|
+
const NAME = /^[a-z][a-z0-9_]{0,63}$/u;
|
|
9
|
+
const ACTION = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/u;
|
|
10
|
+
/** The JSON Schema keywords a tool input may use (validate.ts subset, ops.md section 19). */
|
|
11
|
+
export const INPUT_KEYWORDS = [
|
|
12
|
+
"type",
|
|
13
|
+
"properties",
|
|
14
|
+
"required",
|
|
15
|
+
"additionalProperties",
|
|
16
|
+
"items",
|
|
17
|
+
"minItems",
|
|
18
|
+
"maxItems",
|
|
19
|
+
"enum",
|
|
20
|
+
"const",
|
|
21
|
+
"minimum",
|
|
22
|
+
"maximum",
|
|
23
|
+
"exclusiveMinimum",
|
|
24
|
+
"exclusiveMaximum",
|
|
25
|
+
"minLength",
|
|
26
|
+
"maxLength",
|
|
27
|
+
"pattern",
|
|
28
|
+
"format",
|
|
29
|
+
"description",
|
|
30
|
+
"default",
|
|
31
|
+
"anyOf",
|
|
32
|
+
"nullable",
|
|
33
|
+
];
|
|
34
|
+
const KEEP = new Set(INPUT_KEYWORDS);
|
|
35
|
+
/** Values below which a number bound is the integer range zod adds, not the author's. */
|
|
36
|
+
const SAFE = Number.MAX_SAFE_INTEGER;
|
|
37
|
+
/** A JSON Schema reduced to `INPUT_KEYWORDS`, recursively; bounds at the safe-integer range are dropped. */
|
|
38
|
+
function restrict(s) {
|
|
39
|
+
if (s === null || typeof s !== "object" || Array.isArray(s))
|
|
40
|
+
return {};
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(s)) {
|
|
43
|
+
if (!KEEP.has(k))
|
|
44
|
+
continue;
|
|
45
|
+
if ((k === "minimum" || k === "maximum") && typeof v === "number" && Math.abs(v) >= SAFE)
|
|
46
|
+
continue;
|
|
47
|
+
if (k === "properties" && v && typeof v === "object")
|
|
48
|
+
out[k] = Object.fromEntries(Object.entries(v).map(([p, x]) => [p, restrict(x)]));
|
|
49
|
+
else if (k === "items")
|
|
50
|
+
out[k] = restrict(v);
|
|
51
|
+
else if (k === "anyOf" && Array.isArray(v))
|
|
52
|
+
out[k] = v.map(restrict);
|
|
53
|
+
else if (k === "additionalProperties" && typeof v === "object")
|
|
54
|
+
out[k] = restrict(v);
|
|
55
|
+
else
|
|
56
|
+
out[k] = v;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The JSON Schema of a Standard Schema input: through the Standard JSON
|
|
62
|
+
* Schema interface (`~standard.jsonSchema.input`, zod 4.2+), else a
|
|
63
|
+
* `toJSONSchema()` method, else the schema itself when it is plain JSON
|
|
64
|
+
* Schema. Throws a TypeError when none applies.
|
|
65
|
+
*/
|
|
66
|
+
export function toolInputSchema(schema) {
|
|
67
|
+
const std = schema?.["~standard"];
|
|
68
|
+
let raw;
|
|
69
|
+
if (typeof std?.jsonSchema?.input === "function")
|
|
70
|
+
raw = std.jsonSchema.input({ target: "draft-2020-12" });
|
|
71
|
+
else if (typeof schema?.toJSONSchema === "function")
|
|
72
|
+
raw = schema.toJSONSchema();
|
|
73
|
+
else if (schema && typeof schema === "object" && typeof schema.type === "string" && !std)
|
|
74
|
+
raw = schema;
|
|
75
|
+
else
|
|
76
|
+
throw new TypeError("tool: the input schema cannot be described as JSON Schema (use zod 4, or a schema with ~standard.jsonSchema)");
|
|
77
|
+
const out = restrict(raw);
|
|
78
|
+
if (out.type !== "object")
|
|
79
|
+
throw new TypeError("tool: the input schema must describe an object");
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Declares a tool. Checks `name`, `description`, `action` and that the
|
|
84
|
+
* input is a Standard Schema; returns the definition with `confirm`
|
|
85
|
+
* defaulted to false.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* export default tool({ name: "create_invoice", description: "...", input: z.object({ ... }), action: "invoice.create", async run(ctx) { ... } });
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function tool(def) {
|
|
92
|
+
if (!NAME.test(def.name))
|
|
93
|
+
throw new TypeError(`tool: name must match ^[a-z][a-z0-9_]{0,63}$, got ${JSON.stringify(def.name)}`);
|
|
94
|
+
if (typeof def.description !== "string" || def.description.length === 0 || def.description.length > 200)
|
|
95
|
+
throw new TypeError("tool: description is 1 to 200 characters");
|
|
96
|
+
if (!ACTION.test(def.action))
|
|
97
|
+
throw new TypeError(`tool: action must be <resource>.<verb>, got ${JSON.stringify(def.action)}`);
|
|
98
|
+
if (typeof def.input?.["~standard"]?.validate !== "function")
|
|
99
|
+
throw new TypeError("tool: input must be a Standard Schema (zod, valibot, arktype, ...)");
|
|
100
|
+
if (typeof def.run !== "function")
|
|
101
|
+
throw new TypeError("tool: run must be a function");
|
|
102
|
+
if (def.confirm !== undefined && typeof def.confirm !== "boolean")
|
|
103
|
+
throw new TypeError("tool: confirm is a boolean");
|
|
104
|
+
return { ...def, input: def.input, confirm: def.confirm ?? false };
|
|
105
|
+
}
|