@objectstack/verify 9.11.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 +202 -0
- package/LICENSE.apache +202 -0
- package/README.md +116 -0
- package/dist/index.cjs +547 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +172 -0
- package/dist/index.d.ts +172 -0
- package/dist/index.js +504 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
bootStack: () => bootStack,
|
|
34
|
+
deriveCrudCases: () => deriveCrudCases,
|
|
35
|
+
fillRelationalRefs: () => fillRelationalRefs,
|
|
36
|
+
formatReport: () => formatReport,
|
|
37
|
+
formatRlsReport: () => formatRlsReport,
|
|
38
|
+
runCrudVerification: () => runCrudVerification,
|
|
39
|
+
runRlsProofs: () => runRlsProofs
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(index_exports);
|
|
42
|
+
|
|
43
|
+
// src/harness.ts
|
|
44
|
+
var import_runtime = require("@objectstack/runtime");
|
|
45
|
+
var import_objectql = require("@objectstack/objectql");
|
|
46
|
+
var import_driver_sqlite_wasm = require("@objectstack/driver-sqlite-wasm");
|
|
47
|
+
var import_plugin_hono_server = require("@objectstack/plugin-hono-server");
|
|
48
|
+
var import_rest = require("@objectstack/rest");
|
|
49
|
+
var import_plugin_auth = require("@objectstack/plugin-auth");
|
|
50
|
+
var import_plugin_security = require("@objectstack/plugin-security");
|
|
51
|
+
var import_plugin_sharing = require("@objectstack/plugin-sharing");
|
|
52
|
+
var import_service_settings = require("@objectstack/service-settings");
|
|
53
|
+
var import_service_analytics = require("@objectstack/service-analytics");
|
|
54
|
+
var API_PREFIX = "/api/v1";
|
|
55
|
+
var DEFAULT_ADMIN_EMAIL = "admin@objectos.ai";
|
|
56
|
+
var DEFAULT_ADMIN_PASSWORD = "admin123";
|
|
57
|
+
var DEFAULT_AUTH_SECRET = "objectstack-verify-secret";
|
|
58
|
+
async function bootStack(config, opts = {}) {
|
|
59
|
+
process.env.NODE_ENV = "development";
|
|
60
|
+
const kernel = new import_runtime.ObjectKernel();
|
|
61
|
+
await kernel.use(new import_objectql.ObjectQLPlugin());
|
|
62
|
+
await kernel.use(new import_runtime.DriverPlugin(new import_driver_sqlite_wasm.SqliteWasmDriver({ filename: ":memory:" })));
|
|
63
|
+
await kernel.use(new import_plugin_hono_server.HonoServerPlugin({ port: 0 }));
|
|
64
|
+
await kernel.use(new import_runtime.AppPlugin(config));
|
|
65
|
+
await kernel.use(new import_service_settings.SettingsServicePlugin());
|
|
66
|
+
await kernel.use(new import_service_analytics.AnalyticsServicePlugin());
|
|
67
|
+
await kernel.use(new import_plugin_auth.AuthPlugin({ secret: opts.authSecret ?? DEFAULT_AUTH_SECRET }));
|
|
68
|
+
if (opts.multiTenant) {
|
|
69
|
+
const { OrgScopingPlugin } = await import("@objectstack/plugin-org-scoping");
|
|
70
|
+
await kernel.use(new OrgScopingPlugin());
|
|
71
|
+
}
|
|
72
|
+
if (opts.automation) {
|
|
73
|
+
const { AutomationServicePlugin } = await import("@objectstack/service-automation");
|
|
74
|
+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: "memory" }));
|
|
75
|
+
}
|
|
76
|
+
await kernel.use(opts.security ?? new import_plugin_security.SecurityPlugin());
|
|
77
|
+
await kernel.use(new import_plugin_sharing.SharingServicePlugin());
|
|
78
|
+
await kernel.use((0, import_rest.createRestApiPlugin)({ api: { api: { requireAuth: true } } }));
|
|
79
|
+
await kernel.use((0, import_runtime.createDispatcherPlugin)({}));
|
|
80
|
+
await kernel.bootstrap();
|
|
81
|
+
try {
|
|
82
|
+
const engine = await kernel.getServiceAsync("objectql");
|
|
83
|
+
if (engine && typeof engine.setCryptoProvider === "function") {
|
|
84
|
+
engine.setCryptoProvider(new import_service_settings.LocalCryptoProvider());
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
}
|
|
88
|
+
const httpServer = await kernel.getServiceAsync(
|
|
89
|
+
"http-server"
|
|
90
|
+
);
|
|
91
|
+
const app = httpServer.getRawApp();
|
|
92
|
+
const ORIGIN = "http://localhost:3000";
|
|
93
|
+
const raw = (path, init) => app.request(`${ORIGIN}${path}`, init);
|
|
94
|
+
const api = (path, init) => raw(`${API_PREFIX}${path}`, init);
|
|
95
|
+
const admin = opts.admin ?? { email: DEFAULT_ADMIN_EMAIL, password: DEFAULT_ADMIN_PASSWORD };
|
|
96
|
+
const signIn = async (email = admin.email, password = admin.password) => {
|
|
97
|
+
const res = await api("/auth/sign-in/email", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({ email, password })
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
throw new Error(`verify signIn failed: ${res.status} ${await res.text()}`);
|
|
104
|
+
}
|
|
105
|
+
const data = await res.json();
|
|
106
|
+
if (!data.token) throw new Error("verify signIn: no token in response");
|
|
107
|
+
return data.token;
|
|
108
|
+
};
|
|
109
|
+
const signUp = async (email, password = "Member-Pass-123", name) => {
|
|
110
|
+
const res = await api("/auth/sign-up/email", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ email, password, name: name ?? email.split("@")[0] })
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
throw new Error(`verify signUp failed: ${res.status} ${await res.text()}`);
|
|
117
|
+
}
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
if (!data.token) throw new Error("verify signUp: no token in response");
|
|
120
|
+
return data.token;
|
|
121
|
+
};
|
|
122
|
+
const apiAs = (token, method, path, body) => api(path, {
|
|
123
|
+
method,
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
Authorization: `Bearer ${token}`
|
|
127
|
+
},
|
|
128
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
129
|
+
});
|
|
130
|
+
const stop = async () => {
|
|
131
|
+
try {
|
|
132
|
+
await httpServer.close?.();
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await kernel.shutdown?.();
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return { kernel, api, raw, signIn, signUp, apiAs, stop };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/derive.ts
|
|
144
|
+
var COMPUTED = /* @__PURE__ */ new Set(["formula", "summary", "autonumber", "rollup", "vector"]);
|
|
145
|
+
var RELATIONAL = /* @__PURE__ */ new Set(["lookup", "master_detail", "master-detail", "masterdetail", "tree"]);
|
|
146
|
+
var STRUCTURED = /* @__PURE__ */ new Set(["composite", "repeater", "record", "location", "address"]);
|
|
147
|
+
var MEDIA = /* @__PURE__ */ new Set(["image", "file", "avatar", "video", "audio", "signature", "qrcode"]);
|
|
148
|
+
var SYSTEM_NAMES = /* @__PURE__ */ new Set([
|
|
149
|
+
"id",
|
|
150
|
+
"created_at",
|
|
151
|
+
"updated_at",
|
|
152
|
+
"created_by",
|
|
153
|
+
"updated_by",
|
|
154
|
+
"owner",
|
|
155
|
+
"space",
|
|
156
|
+
"instance_state",
|
|
157
|
+
"record_id",
|
|
158
|
+
"is_deleted"
|
|
159
|
+
]);
|
|
160
|
+
function clampNum(f, fallback) {
|
|
161
|
+
const { min, max, step } = f;
|
|
162
|
+
let v = fallback;
|
|
163
|
+
if (typeof min === "number" && v < min) v = min;
|
|
164
|
+
if (typeof max === "number" && v > max) v = max;
|
|
165
|
+
if (typeof step === "number" && typeof min === "number") {
|
|
166
|
+
v = min + step * Math.round((v - min) / step);
|
|
167
|
+
}
|
|
168
|
+
return v;
|
|
169
|
+
}
|
|
170
|
+
function synth(type, f) {
|
|
171
|
+
switch (type) {
|
|
172
|
+
case "text":
|
|
173
|
+
case "textarea":
|
|
174
|
+
case "string":
|
|
175
|
+
case "markdown":
|
|
176
|
+
case "html":
|
|
177
|
+
case "richtext":
|
|
178
|
+
case "code":
|
|
179
|
+
return { value: "verify-sample", kind: "equal" };
|
|
180
|
+
case "email":
|
|
181
|
+
return { value: "verify@example.com", kind: "equal" };
|
|
182
|
+
case "url":
|
|
183
|
+
return { value: "https://example.com", kind: "equal" };
|
|
184
|
+
case "phone":
|
|
185
|
+
return { value: "+14155550100", kind: "equal" };
|
|
186
|
+
case "color":
|
|
187
|
+
return { value: "#3366CC", kind: "equal" };
|
|
188
|
+
case "number":
|
|
189
|
+
return { value: clampNum(f, 7), kind: "equal" };
|
|
190
|
+
case "currency":
|
|
191
|
+
return { value: clampNum(f, 100), kind: "equal" };
|
|
192
|
+
case "percent":
|
|
193
|
+
return { value: clampNum(f, 50), kind: "equal" };
|
|
194
|
+
case "rating":
|
|
195
|
+
return { value: clampNum(f, Math.min(3, f.max ?? 5)), kind: "equal" };
|
|
196
|
+
case "slider":
|
|
197
|
+
case "progress":
|
|
198
|
+
return { value: clampNum(f, 25), kind: "equal" };
|
|
199
|
+
case "boolean":
|
|
200
|
+
case "toggle":
|
|
201
|
+
return { value: true, kind: "equal" };
|
|
202
|
+
case "date":
|
|
203
|
+
return { value: "2024-03-15", kind: "equal" };
|
|
204
|
+
case "datetime":
|
|
205
|
+
return { value: "2024-03-15T08:30:00.000Z", kind: "equal" };
|
|
206
|
+
case "time":
|
|
207
|
+
return { value: "14:30:00", kind: "equal" };
|
|
208
|
+
case "json":
|
|
209
|
+
return { value: { sample: true }, kind: "equal" };
|
|
210
|
+
case "select":
|
|
211
|
+
case "radio": {
|
|
212
|
+
const opt = f.options?.[0]?.value;
|
|
213
|
+
return opt != null ? { value: opt, kind: "equal" } : null;
|
|
214
|
+
}
|
|
215
|
+
case "multiselect":
|
|
216
|
+
case "checkboxes": {
|
|
217
|
+
const opt = f.options?.[0]?.value;
|
|
218
|
+
return opt != null ? { value: [opt], kind: "set" } : null;
|
|
219
|
+
}
|
|
220
|
+
case "tags":
|
|
221
|
+
return { value: ["alpha", "beta"], kind: "set" };
|
|
222
|
+
// Opaque-on-read: write a value but don't assert a round-trip (hashed/encrypted).
|
|
223
|
+
case "password":
|
|
224
|
+
case "secret":
|
|
225
|
+
return { value: "Sample-Secret-123", kind: "none" };
|
|
226
|
+
default:
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function relationTarget(f) {
|
|
231
|
+
const ref = f?.reference ?? f?.reference_to ?? f?.referenceTo;
|
|
232
|
+
return typeof ref === "string" && ref.length > 0 ? ref : null;
|
|
233
|
+
}
|
|
234
|
+
function deriveCrudCases(config) {
|
|
235
|
+
const objects = config?.objects ?? [];
|
|
236
|
+
const byName = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const o of objects) if (o?.name) byName.set(o.name, o);
|
|
238
|
+
const drafts = /* @__PURE__ */ new Map();
|
|
239
|
+
for (const obj of objects) {
|
|
240
|
+
if (!obj?.name) continue;
|
|
241
|
+
const fields = obj?.fields ?? {};
|
|
242
|
+
const d = {
|
|
243
|
+
name: obj.name,
|
|
244
|
+
body: {},
|
|
245
|
+
asserts: [],
|
|
246
|
+
skippedFields: [],
|
|
247
|
+
relationalRefs: [],
|
|
248
|
+
requiredTargets: []
|
|
249
|
+
};
|
|
250
|
+
for (const [name, f] of Object.entries(fields)) {
|
|
251
|
+
const type = String(f?.type ?? "").toLowerCase();
|
|
252
|
+
const isRequired = !!f?.required;
|
|
253
|
+
if (SYSTEM_NAMES.has(name) || f?.system || f?.readonly) continue;
|
|
254
|
+
if (COMPUTED.has(type)) continue;
|
|
255
|
+
if (RELATIONAL.has(type)) {
|
|
256
|
+
const target = relationTarget(f);
|
|
257
|
+
if (!target) {
|
|
258
|
+
if (isRequired) {
|
|
259
|
+
d.blocked = `required ${type} field "${name}" has no \`reference\` target`;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
d.skippedFields.push({ name, type, reason: "relation-missing-reference" });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!byName.has(target)) {
|
|
266
|
+
if (isRequired) {
|
|
267
|
+
d.blocked = `required ${type} field "${name}" \u2192 target "${target}" not in app config`;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
d.skippedFields.push({ name, type, reason: `relation-target-external:${target}` });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
d.relationalRefs.push({ field: name, target, required: isRequired, multiple: !!f?.multiple });
|
|
274
|
+
if (isRequired) d.requiredTargets.push(target);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (STRUCTURED.has(type) || MEDIA.has(type)) {
|
|
278
|
+
if (isRequired) {
|
|
279
|
+
d.blocked = `required ${type} field "${name}" (needs structured/media value)`;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
d.skippedFields.push({ name, type, reason: "unsynthesizable-optional" });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const s = synth(type, f);
|
|
286
|
+
if (!s) {
|
|
287
|
+
if (isRequired) {
|
|
288
|
+
d.blocked = `required field "${name}" of type "${type}" is not synthesizable`;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
d.skippedFields.push({ name, type, reason: "no-synth" });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
d.body[name] = s.value;
|
|
295
|
+
if (s.kind !== "none") d.asserts.push({ field: name, type, value: s.value, kind: s.kind });
|
|
296
|
+
}
|
|
297
|
+
drafts.set(obj.name, d);
|
|
298
|
+
}
|
|
299
|
+
let changed = true;
|
|
300
|
+
while (changed) {
|
|
301
|
+
changed = false;
|
|
302
|
+
for (const d of drafts.values()) {
|
|
303
|
+
if (d.blocked) continue;
|
|
304
|
+
for (const t of d.requiredTargets) {
|
|
305
|
+
const td = drafts.get(t);
|
|
306
|
+
if (!td || td.blocked) {
|
|
307
|
+
d.blocked = `required relational target "${t}" is ${!td ? "missing" : "not synthesizable"}`;
|
|
308
|
+
changed = true;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
315
|
+
const order = [];
|
|
316
|
+
const live = [...drafts.values()].filter((d) => !d.blocked).map((d) => d.name);
|
|
317
|
+
let progress = true;
|
|
318
|
+
while (progress) {
|
|
319
|
+
progress = false;
|
|
320
|
+
for (const name of live) {
|
|
321
|
+
if (emitted.has(name)) continue;
|
|
322
|
+
const d = drafts.get(name);
|
|
323
|
+
if (d.requiredTargets.every((t) => emitted.has(t))) {
|
|
324
|
+
emitted.add(name);
|
|
325
|
+
order.push(name);
|
|
326
|
+
progress = true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const name of live) {
|
|
331
|
+
if (!emitted.has(name)) drafts.get(name).blocked = "unsatisfiable required-reference cycle";
|
|
332
|
+
}
|
|
333
|
+
const cases = [];
|
|
334
|
+
for (const name of order) {
|
|
335
|
+
const d = drafts.get(name);
|
|
336
|
+
cases.push({
|
|
337
|
+
object: d.name,
|
|
338
|
+
body: d.body,
|
|
339
|
+
asserts: d.asserts,
|
|
340
|
+
skippedFields: d.skippedFields,
|
|
341
|
+
...d.relationalRefs.length ? { relationalRefs: d.relationalRefs } : {}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
for (const d of drafts.values()) {
|
|
345
|
+
if (d.blocked) cases.push({ object: d.name, blocked: d.blocked });
|
|
346
|
+
}
|
|
347
|
+
return cases;
|
|
348
|
+
}
|
|
349
|
+
function fillRelationalRefs(c, created) {
|
|
350
|
+
const body = { ...c.body ?? {} };
|
|
351
|
+
for (const ref of c.relationalRefs ?? []) {
|
|
352
|
+
const id = created.get(ref.target);
|
|
353
|
+
if (id == null) {
|
|
354
|
+
if (ref.required) return { body, missing: `required relation "${ref.field}" \u2192 no created "${ref.target}" record` };
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
body[ref.field] = ref.multiple ? [id] : id;
|
|
358
|
+
}
|
|
359
|
+
return { body };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/verify.ts
|
|
363
|
+
function setEqual(a, b) {
|
|
364
|
+
if (!Array.isArray(a)) return false;
|
|
365
|
+
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
366
|
+
}
|
|
367
|
+
function deepEqual(a, b) {
|
|
368
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
369
|
+
}
|
|
370
|
+
async function runCrudVerification(stack, token, config) {
|
|
371
|
+
const cases = deriveCrudCases(config);
|
|
372
|
+
const results = [];
|
|
373
|
+
const createdIds = /* @__PURE__ */ new Map();
|
|
374
|
+
for (const c of cases) {
|
|
375
|
+
if (c.blocked) {
|
|
376
|
+
results.push({ object: c.object, status: "skipped", reason: c.blocked });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const { body, missing } = fillRelationalRefs(c, createdIds);
|
|
380
|
+
if (missing) {
|
|
381
|
+
results.push({ object: c.object, status: "skipped", reason: missing });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
let created;
|
|
385
|
+
try {
|
|
386
|
+
created = await stack.apiAs(token, "POST", `/data/${c.object}`, body);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
results.push({ object: c.object, status: "create-failed", detail: String(e?.message ?? e).slice(0, 200) });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (created.status >= 300) {
|
|
392
|
+
const text = (await created.text()).slice(0, 280);
|
|
393
|
+
const isValidation = created.status === 400 && /VALIDATION_FAILED|Validation failed/i.test(text);
|
|
394
|
+
results.push({
|
|
395
|
+
object: c.object,
|
|
396
|
+
status: isValidation ? "needs-fixture" : "create-failed",
|
|
397
|
+
code: created.status,
|
|
398
|
+
detail: text
|
|
399
|
+
});
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const cj = await created.json();
|
|
403
|
+
const id = cj?.id ?? cj?.record?.id;
|
|
404
|
+
if (!id) {
|
|
405
|
+
results.push({ object: c.object, status: "create-failed", detail: "no id returned" });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
createdIds.set(c.object, String(id));
|
|
409
|
+
const got = await stack.apiAs(token, "GET", `/data/${c.object}/${id}`);
|
|
410
|
+
if (got.status !== 200) {
|
|
411
|
+
results.push({ object: c.object, status: "read-failed", code: got.status });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const rec = (await got.json())?.record ?? {};
|
|
415
|
+
const mismatches = [];
|
|
416
|
+
for (const a of c.asserts ?? []) {
|
|
417
|
+
const actual = rec[a.field];
|
|
418
|
+
const ok = a.kind === "set" ? setEqual(actual, a.value) : deepEqual(actual, a.value);
|
|
419
|
+
if (!ok) mismatches.push({ field: a.field, type: a.type, wrote: a.value, read: actual });
|
|
420
|
+
}
|
|
421
|
+
results.push({
|
|
422
|
+
object: c.object,
|
|
423
|
+
status: mismatches.length ? "fidelity-gaps" : "verified",
|
|
424
|
+
checked: (c.asserts ?? []).length,
|
|
425
|
+
...mismatches.length ? { mismatches } : {}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const summary = {
|
|
429
|
+
objects: results.length,
|
|
430
|
+
verified: results.filter((r) => r.status === "verified").length,
|
|
431
|
+
fidelityGaps: results.filter((r) => r.status === "fidelity-gaps").length,
|
|
432
|
+
createFailed: results.filter((r) => r.status === "create-failed").length,
|
|
433
|
+
needsFixture: results.filter((r) => r.status === "needs-fixture").length,
|
|
434
|
+
readFailed: results.filter((r) => r.status === "read-failed").length,
|
|
435
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
436
|
+
mismatchTotal: results.reduce((n, r) => n + (r.mismatches?.length ?? 0), 0)
|
|
437
|
+
};
|
|
438
|
+
return { app: config?.manifest?.id ?? config?.manifest?.namespace ?? "app", results, summary };
|
|
439
|
+
}
|
|
440
|
+
function formatReport(report) {
|
|
441
|
+
const lines = [`
|
|
442
|
+
=== objectstack verify \u2014 ${report.app} ===`];
|
|
443
|
+
for (const r of report.results) {
|
|
444
|
+
if (r.status === "verified") lines.push(` \u2713 ${r.object} (${r.checked} fields)`);
|
|
445
|
+
else if (r.status === "fidelity-gaps") {
|
|
446
|
+
lines.push(` \u26A0 ${r.object} ${r.mismatches.length} fidelity gap(s):`);
|
|
447
|
+
for (const m of r.mismatches) lines.push(` ${m.field} <${m.type}>: wrote ${JSON.stringify(m.wrote)} \u2192 read ${JSON.stringify(m.read)}`);
|
|
448
|
+
} else if (r.status === "skipped") lines.push(` \u2013 ${r.object} skipped: ${r.reason}`);
|
|
449
|
+
else if (r.status === "needs-fixture") lines.push(` ~ ${r.object} needs-fixture (app validation rejected the auto-record): ${(r.detail ?? "").slice(0, 120)}`);
|
|
450
|
+
else lines.push(` \u2717 ${r.object} ${r.status}${r.code ? ` (${r.code})` : ""}: ${r.detail ?? ""}`);
|
|
451
|
+
}
|
|
452
|
+
const s = report.summary;
|
|
453
|
+
lines.push(` \u2500\u2500 ${s.verified} verified, ${s.fidelityGaps} gaps, ${s.createFailed + s.readFailed} FAILED, ${s.needsFixture} needs-fixture, ${s.skipped} skipped (${s.mismatchTotal} mismatches)`);
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/rls.ts
|
|
458
|
+
var PROBE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "string"]);
|
|
459
|
+
var MUTATION = "rls-mutated-by-B";
|
|
460
|
+
async function runRlsProofs(stack, adminToken, memberToken, config) {
|
|
461
|
+
const cases = deriveCrudCases(config);
|
|
462
|
+
const results = [];
|
|
463
|
+
const createdIds = /* @__PURE__ */ new Map();
|
|
464
|
+
for (const c of cases) {
|
|
465
|
+
if (c.blocked) {
|
|
466
|
+
results.push({ object: c.object, status: "skipped", detail: c.blocked });
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const probe = (c.asserts ?? []).find((a) => PROBE_TYPES.has(a.type));
|
|
470
|
+
if (!probe) {
|
|
471
|
+
results.push({ object: c.object, status: "skipped", detail: "no plain-text probe field" });
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const { body, missing } = fillRelationalRefs(c, createdIds);
|
|
475
|
+
if (missing) {
|
|
476
|
+
results.push({ object: c.object, status: "skipped", detail: missing });
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const created = await stack.apiAs(adminToken, "POST", `/data/${c.object}`, body);
|
|
480
|
+
if (created.status >= 300) {
|
|
481
|
+
results.push({ object: c.object, status: "skipped", detail: `admin create failed (${created.status})` });
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const cj = await created.json();
|
|
485
|
+
const id = cj?.id ?? cj?.record?.id;
|
|
486
|
+
if (!id) {
|
|
487
|
+
results.push({ object: c.object, status: "skipped", detail: "no id from create" });
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
createdIds.set(c.object, String(id));
|
|
491
|
+
const bRead = await stack.apiAs(memberToken, "GET", `/data/${c.object}/${id}`);
|
|
492
|
+
let canRead = false;
|
|
493
|
+
if (bRead.status === 200) {
|
|
494
|
+
const rec = (await bRead.json())?.record;
|
|
495
|
+
canRead = !!rec && rec.id === id;
|
|
496
|
+
}
|
|
497
|
+
const bWrite = await stack.apiAs(memberToken, "PATCH", `/data/${c.object}/${id}`, { [probe.field]: MUTATION });
|
|
498
|
+
const after = await stack.apiAs(adminToken, "GET", `/data/${c.object}/${id}`);
|
|
499
|
+
const afterVal = ((await after.json())?.record ?? {})[probe.field];
|
|
500
|
+
const changed = afterVal === MUTATION;
|
|
501
|
+
if (canRead) {
|
|
502
|
+
results.push({ object: c.object, status: "member-visible", detail: "member can read this object \u2014 not a cross-owner scenario (no RLS isolation, or read is granted)" });
|
|
503
|
+
} else if (changed) {
|
|
504
|
+
results.push({
|
|
505
|
+
object: c.object,
|
|
506
|
+
status: "rls-hole",
|
|
507
|
+
detail: `member B cannot read it (GET ${bRead.status}) yet MUTATED it by id (PATCH ${bWrite.status}) \u2014 by-id write bypassed RLS (#1994 class)`
|
|
508
|
+
});
|
|
509
|
+
} else {
|
|
510
|
+
results.push({
|
|
511
|
+
object: c.object,
|
|
512
|
+
status: "rls-consistent",
|
|
513
|
+
detail: `member B cannot read (GET ${bRead.status}) and could not mutate (PATCH ${bWrite.status}, row unchanged)`
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const summary = {
|
|
518
|
+
objects: results.length,
|
|
519
|
+
consistent: results.filter((r) => r.status === "rls-consistent").length,
|
|
520
|
+
holes: results.filter((r) => r.status === "rls-hole").length,
|
|
521
|
+
memberVisible: results.filter((r) => r.status === "member-visible").length,
|
|
522
|
+
skipped: results.filter((r) => r.status === "skipped").length
|
|
523
|
+
};
|
|
524
|
+
return { app: config?.manifest?.id ?? "app", results, summary };
|
|
525
|
+
}
|
|
526
|
+
function formatRlsReport(report) {
|
|
527
|
+
const lines = [`
|
|
528
|
+
=== objectstack verify (RLS / #1994) \u2014 ${report.app} ===`];
|
|
529
|
+
for (const r of report.results) {
|
|
530
|
+
const mark = r.status === "rls-hole" ? "\u2717\u2717" : r.status === "rls-consistent" ? "\u2713" : r.status === "member-visible" ? "\xB7" : "\u2013";
|
|
531
|
+
lines.push(` ${mark} ${r.object} [${r.status}] ${r.detail ?? ""}`);
|
|
532
|
+
}
|
|
533
|
+
const s = report.summary;
|
|
534
|
+
lines.push(` \u2500\u2500 ${s.consistent} consistent, ${s.holes} HOLES, ${s.memberVisible} member-visible, ${s.skipped} skipped`);
|
|
535
|
+
return lines.join("\n");
|
|
536
|
+
}
|
|
537
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
538
|
+
0 && (module.exports = {
|
|
539
|
+
bootStack,
|
|
540
|
+
deriveCrudCases,
|
|
541
|
+
fillRelationalRefs,
|
|
542
|
+
formatReport,
|
|
543
|
+
formatRlsReport,
|
|
544
|
+
runCrudVerification,
|
|
545
|
+
runRlsProofs
|
|
546
|
+
});
|
|
547
|
+
//# sourceMappingURL=index.cjs.map
|