@objectstack/runtime 9.8.0 → 9.9.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/dist/index.cjs +239 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -2
- package/dist/index.d.ts +23 -2
- package/dist/index.js +239 -15
- package/dist/index.js.map +1 -1
- package/package.json +18 -18
package/dist/index.cjs
CHANGED
|
@@ -228,7 +228,7 @@ var init_package_state_store = __esm({
|
|
|
228
228
|
|
|
229
229
|
// src/sandbox/quickjs-runner.ts
|
|
230
230
|
function installApiMethod(vm, parent, method, objectName, ctx, caps, required, origin) {
|
|
231
|
-
const fn = vm.
|
|
231
|
+
const fn = vm.newFunction(method, (...argHandles) => {
|
|
232
232
|
if (!caps.has(required)) {
|
|
233
233
|
throw new SandboxError(
|
|
234
234
|
`capability '${required}' not granted to ${origin.kind} '${origin.name}' (called ctx.api.object('${objectName}').${method})`
|
|
@@ -239,13 +239,27 @@ function installApiMethod(vm, parent, method, objectName, ctx, caps, required, o
|
|
|
239
239
|
throw new SandboxError(`ctx.api unavailable in ${origin.kind} '${origin.name}'`);
|
|
240
240
|
}
|
|
241
241
|
const args = argHandles.map((h) => vm.dump(h));
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
242
|
+
const deferred = vm.newPromise();
|
|
243
|
+
void (async () => {
|
|
244
|
+
try {
|
|
245
|
+
const proxy = apiAny.object(objectName);
|
|
246
|
+
const m = proxy[method];
|
|
247
|
+
if (typeof m !== "function") {
|
|
248
|
+
throw new SandboxError(`ctx.api.object('${objectName}').${method} not implemented`);
|
|
249
|
+
}
|
|
250
|
+
const ret = await Promise.resolve(m.apply(proxy, args));
|
|
251
|
+
if (!vm.alive) return;
|
|
252
|
+
const h = jsonToHandle(vm, ret);
|
|
253
|
+
deferred.resolve(h);
|
|
254
|
+
h.dispose();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (!vm.alive) return;
|
|
257
|
+
const errH = err instanceof Error ? vm.newError({ name: err.name || "Error", message: err.message }) : vm.newError({ name: "Error", message: String(err) });
|
|
258
|
+
deferred.reject(errH);
|
|
259
|
+
errH.dispose();
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
return deferred.handle;
|
|
249
263
|
});
|
|
250
264
|
vm.setProp(parent, method, fn);
|
|
251
265
|
fn.dispose();
|
|
@@ -408,7 +422,7 @@ var init_quickjs_runner = __esm({
|
|
|
408
422
|
}
|
|
409
423
|
evalRes.value.dispose();
|
|
410
424
|
let pumps = 0;
|
|
411
|
-
|
|
425
|
+
for (; ; ) {
|
|
412
426
|
await new Promise((resolve) => setImmediate(resolve));
|
|
413
427
|
const pending = runtime.executePendingJobs();
|
|
414
428
|
if (pending.error) {
|
|
@@ -432,14 +446,11 @@ var init_quickjs_runner = __esm({
|
|
|
432
446
|
}
|
|
433
447
|
if (Date.now() > deadline) {
|
|
434
448
|
throw new SandboxError(
|
|
435
|
-
`${args.origin.kind} '${args.origin.name}' exceeded timeout of ${args.timeoutMs}ms`
|
|
449
|
+
`${args.origin.kind} '${args.origin.name}' exceeded timeout of ${args.timeoutMs}ms (after ${pumps} pump iterations)`
|
|
436
450
|
);
|
|
437
451
|
}
|
|
438
452
|
pumps++;
|
|
439
453
|
}
|
|
440
|
-
throw new SandboxError(
|
|
441
|
-
`${args.origin.kind} '${args.origin.name}' did not resolve after ${pumps} pump iterations`
|
|
442
|
-
);
|
|
443
454
|
} finally {
|
|
444
455
|
vm.dispose();
|
|
445
456
|
}
|
|
@@ -449,8 +460,9 @@ var init_quickjs_runner = __esm({
|
|
|
449
460
|
* the body declared it; missing methods throw at call-time inside the VM
|
|
450
461
|
* with a clear diagnostic.
|
|
451
462
|
*
|
|
452
|
-
* Host API methods are installed
|
|
453
|
-
* so they may return Promises (real ObjectQL
|
|
463
|
+
* Host API methods are installed as deferred-promise functions (see
|
|
464
|
+
* {@link installApiMethod}) so they may return Promises (real ObjectQL
|
|
465
|
+
* `find/count/insert/...` are async) without asyncify's single-unwind limit.
|
|
454
466
|
*/
|
|
455
467
|
installCtx(vm, ctx, caps, origin) {
|
|
456
468
|
setGlobalJson(vm, "__input", ctx.input);
|
|
@@ -2007,6 +2019,43 @@ async function tryFind(ql, object, where, limit = 100) {
|
|
|
2007
2019
|
return [];
|
|
2008
2020
|
}
|
|
2009
2021
|
}
|
|
2022
|
+
function isValidTimeZone(tz) {
|
|
2023
|
+
try {
|
|
2024
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
2025
|
+
return true;
|
|
2026
|
+
} catch {
|
|
2027
|
+
return false;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
function coerceTimeZone(value) {
|
|
2031
|
+
const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
|
|
2032
|
+
return s && isValidTimeZone(s) ? s : void 0;
|
|
2033
|
+
}
|
|
2034
|
+
function coerceLocale(value) {
|
|
2035
|
+
const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
|
|
2036
|
+
return s || void 0;
|
|
2037
|
+
}
|
|
2038
|
+
async function resolveLocalization(opts, ql, sctx) {
|
|
2039
|
+
try {
|
|
2040
|
+
const settings = await opts.getService("settings");
|
|
2041
|
+
if (settings && typeof settings.get === "function") {
|
|
2042
|
+
const [tzRes, localeRes] = await Promise.all([
|
|
2043
|
+
settings.get("localization", "timezone", sctx).catch(() => void 0),
|
|
2044
|
+
settings.get("localization", "locale", sctx).catch(() => void 0)
|
|
2045
|
+
]);
|
|
2046
|
+
const tz = coerceTimeZone(tzRes?.value);
|
|
2047
|
+
const locale = coerceLocale(localeRes?.value);
|
|
2048
|
+
if (tz || locale) return { timezone: tz ?? "UTC", locale: locale ?? "en-US" };
|
|
2049
|
+
}
|
|
2050
|
+
} catch {
|
|
2051
|
+
}
|
|
2052
|
+
const tzRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "timezone", scope: "tenant" }, 1);
|
|
2053
|
+
const localeRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "locale", scope: "tenant" }, 1);
|
|
2054
|
+
return {
|
|
2055
|
+
timezone: coerceTimeZone(tzRows[0]?.value) ?? "UTC",
|
|
2056
|
+
locale: coerceLocale(localeRows[0]?.value) ?? "en-US"
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2010
2059
|
async function resolveExecutionContext(opts) {
|
|
2011
2060
|
const headers = opts.request?.headers;
|
|
2012
2061
|
const ctx = {
|
|
@@ -2137,6 +2186,9 @@ async function resolveExecutionContext(opts) {
|
|
|
2137
2186
|
ctx.tabPermissions = mergedTabs;
|
|
2138
2187
|
}
|
|
2139
2188
|
}
|
|
2189
|
+
const localization = await resolveLocalization(opts, ql, { tenantId, userId });
|
|
2190
|
+
ctx.timezone = localization.timezone;
|
|
2191
|
+
ctx.locale = localization.locale;
|
|
2140
2192
|
return ctx;
|
|
2141
2193
|
}
|
|
2142
2194
|
function isPermissionDeniedError(e) {
|
|
@@ -2636,6 +2688,7 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2636
2688
|
if (!this.enforceMembership) return null;
|
|
2637
2689
|
const skipPaths = ["/auth", "/cloud", "/health", "/discovery"];
|
|
2638
2690
|
if (skipPaths.some((p) => path.startsWith(p))) return null;
|
|
2691
|
+
if (/(^|\/)share-links\/[^/]+\/(resolve|messages)$/.test(path)) return null;
|
|
2639
2692
|
const environmentId = context.environmentId;
|
|
2640
2693
|
if (!environmentId) return null;
|
|
2641
2694
|
if (environmentId === _HttpDispatcher.SYSTEM_ENVIRONMENT_ID) return null;
|
|
@@ -4232,6 +4285,174 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4232
4285
|
response: this.routeNotFound(subPath)
|
|
4233
4286
|
};
|
|
4234
4287
|
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Share-link capability tokens — "anyone with the link" publication of a
|
|
4290
|
+
* single record (ADR-0047). Mirrors the per-env service-dispatch pattern
|
|
4291
|
+
* used by {@link handleI18n} / {@link handleAI}: the `shareLinks` service
|
|
4292
|
+
* is resolved from the request's environment kernel, so links live in (and
|
|
4293
|
+
* resolve against) the same per-environment database that owns the record.
|
|
4294
|
+
* This branch owns URL parsing and the auth/public split.
|
|
4295
|
+
*
|
|
4296
|
+
* POST /share-links → create a link (authenticated)
|
|
4297
|
+
* GET /share-links?object&recordId → list the caller's links (authenticated)
|
|
4298
|
+
* DELETE /share-links/:idOrToken → revoke (authenticated)
|
|
4299
|
+
* GET /share-links/:token/resolve → resolve token → record (PUBLIC)
|
|
4300
|
+
* GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
|
|
4301
|
+
*
|
|
4302
|
+
* The resolve / messages routes are intentionally public — the token IS
|
|
4303
|
+
* the authorisation. The underlying record is fetched with a SYSTEM
|
|
4304
|
+
* context (per-env RLS is bypassed because the token gates access), and
|
|
4305
|
+
* `redactFields` are stripped before the record leaves the server.
|
|
4306
|
+
*/
|
|
4307
|
+
async handleShareLinks(subPath, method, body, query, context) {
|
|
4308
|
+
const svc = await this.resolveService("shareLinks", context.environmentId);
|
|
4309
|
+
if (!svc) {
|
|
4310
|
+
return { handled: true, response: this.error("Sharing is not configured for this environment", 501) };
|
|
4311
|
+
}
|
|
4312
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
4313
|
+
const m = method.toUpperCase();
|
|
4314
|
+
const parts = subPath.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
4315
|
+
const ec = context.executionContext;
|
|
4316
|
+
const callerCtx = { userId: ec?.userId, tenantId: ec?.tenantId };
|
|
4317
|
+
const headerOf = (name) => {
|
|
4318
|
+
const h = context.request?.headers;
|
|
4319
|
+
if (!h) return void 0;
|
|
4320
|
+
const v = typeof h.get === "function" ? h.get(name) : h[name] ?? h[name.toLowerCase()];
|
|
4321
|
+
return Array.isArray(v) ? v[0] : v ?? void 0;
|
|
4322
|
+
};
|
|
4323
|
+
const sendErr = (status, code, msg) => ({
|
|
4324
|
+
handled: true,
|
|
4325
|
+
response: this.error(msg, status, { code })
|
|
4326
|
+
});
|
|
4327
|
+
const getEngine = async () => {
|
|
4328
|
+
try {
|
|
4329
|
+
const k = this.kernel;
|
|
4330
|
+
const e = typeof k?.getServiceAsync === "function" ? await k.getServiceAsync("objectql") : k?.getService?.("objectql");
|
|
4331
|
+
if (e) return e;
|
|
4332
|
+
} catch {
|
|
4333
|
+
}
|
|
4334
|
+
return this.resolveService("objectql", context.environmentId);
|
|
4335
|
+
};
|
|
4336
|
+
const asArray = (rows) => Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : [];
|
|
4337
|
+
const applyRedaction = (record, redactFields) => {
|
|
4338
|
+
if (!record || typeof record !== "object" || redactFields.length === 0) return record;
|
|
4339
|
+
const out = {};
|
|
4340
|
+
for (const [k, v] of Object.entries(record)) {
|
|
4341
|
+
if (redactFields.includes(k)) continue;
|
|
4342
|
+
out[k] = v;
|
|
4343
|
+
}
|
|
4344
|
+
return out;
|
|
4345
|
+
};
|
|
4346
|
+
try {
|
|
4347
|
+
if (parts.length === 2 && parts[1] === "resolve" && m === "GET") {
|
|
4348
|
+
const token = decodeURIComponent(parts[0]);
|
|
4349
|
+
const signedInUserId = ec?.userId;
|
|
4350
|
+
const recipientEmail = typeof query?.email === "string" ? query.email : void 0;
|
|
4351
|
+
const providedPassword = typeof query?.password === "string" ? query.password : headerOf("x-share-password");
|
|
4352
|
+
const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword });
|
|
4353
|
+
if (!resolved) {
|
|
4354
|
+
const engine2 = await getEngine();
|
|
4355
|
+
const probe = engine2 ? asArray(await engine2.find("sys_share_link", { where: { token }, limit: 1, context: SYSTEM_CTX })) : [];
|
|
4356
|
+
const row = probe[0] ?? null;
|
|
4357
|
+
const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now());
|
|
4358
|
+
if (live && row.password_hash) {
|
|
4359
|
+
return sendErr(
|
|
4360
|
+
401,
|
|
4361
|
+
providedPassword ? "WRONG_PASSWORD" : "NEEDS_PASSWORD",
|
|
4362
|
+
providedPassword ? "Incorrect password" : "This link requires a password"
|
|
4363
|
+
);
|
|
4364
|
+
}
|
|
4365
|
+
if (live && row.audience === "signed_in" && !signedInUserId) {
|
|
4366
|
+
return sendErr(401, "SIGN_IN_REQUIRED", "Please sign in to view this link");
|
|
4367
|
+
}
|
|
4368
|
+
if (row && (row.revoked_at || row.expires_at && Date.parse(row.expires_at) <= Date.now())) {
|
|
4369
|
+
return sendErr(410, "EXPIRED_OR_REVOKED", "Share link has expired or been revoked");
|
|
4370
|
+
}
|
|
4371
|
+
return sendErr(404, "INVALID_OR_EXPIRED", "Share link is invalid, expired, or revoked");
|
|
4372
|
+
}
|
|
4373
|
+
const engine = await getEngine();
|
|
4374
|
+
const rows = engine ? asArray(await engine.find(resolved.link.object_name, { where: { id: resolved.link.record_id }, limit: 1, context: SYSTEM_CTX })) : [];
|
|
4375
|
+
const record = rows[0] ?? null;
|
|
4376
|
+
if (!record) return sendErr(410, "RECORD_GONE", "The shared record no longer exists");
|
|
4377
|
+
return {
|
|
4378
|
+
handled: true,
|
|
4379
|
+
response: this.success({
|
|
4380
|
+
record: applyRedaction(record, resolved.redactFields),
|
|
4381
|
+
link: {
|
|
4382
|
+
id: resolved.link.id,
|
|
4383
|
+
token: resolved.link.token,
|
|
4384
|
+
object_name: resolved.link.object_name,
|
|
4385
|
+
record_id: resolved.link.record_id,
|
|
4386
|
+
permission: resolved.link.permission,
|
|
4387
|
+
audience: resolved.link.audience,
|
|
4388
|
+
expires_at: resolved.link.expires_at,
|
|
4389
|
+
label: resolved.link.label,
|
|
4390
|
+
created_at: resolved.link.created_at
|
|
4391
|
+
},
|
|
4392
|
+
redactFields: resolved.redactFields
|
|
4393
|
+
})
|
|
4394
|
+
};
|
|
4395
|
+
}
|
|
4396
|
+
if (parts.length === 2 && parts[1] === "messages" && m === "GET") {
|
|
4397
|
+
const token = decodeURIComponent(parts[0]);
|
|
4398
|
+
const providedPassword = typeof query?.password === "string" ? query.password : headerOf("x-share-password");
|
|
4399
|
+
const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword });
|
|
4400
|
+
if (!resolved) return sendErr(404, "NOT_FOUND", "Share link not found");
|
|
4401
|
+
if (resolved.link.object_name !== "ai_conversations") {
|
|
4402
|
+
return sendErr(400, "UNSUPPORTED", "This share link does not expose messages");
|
|
4403
|
+
}
|
|
4404
|
+
const engine = await getEngine();
|
|
4405
|
+
const rows = engine ? asArray(await engine.find("ai_messages", {
|
|
4406
|
+
where: { conversation_id: resolved.link.record_id },
|
|
4407
|
+
sort: [{ field: "created_at", order: "asc" }],
|
|
4408
|
+
limit: 500,
|
|
4409
|
+
context: SYSTEM_CTX
|
|
4410
|
+
})) : [];
|
|
4411
|
+
return { handled: true, response: this.success(rows) };
|
|
4412
|
+
}
|
|
4413
|
+
if (!callerCtx.userId) return sendErr(401, "UNAUTHENTICATED", "Sign in to manage share links");
|
|
4414
|
+
if (parts.length === 0 && m === "POST") {
|
|
4415
|
+
const b = body ?? {};
|
|
4416
|
+
if (!b.object || !b.recordId) return sendErr(400, "VALIDATION_FAILED", "object and recordId are required");
|
|
4417
|
+
const link = await svc.createLink(
|
|
4418
|
+
{
|
|
4419
|
+
object: b.object,
|
|
4420
|
+
recordId: b.recordId,
|
|
4421
|
+
permission: b.permission,
|
|
4422
|
+
audience: b.audience,
|
|
4423
|
+
expiresAt: b.expiresAt ?? null,
|
|
4424
|
+
emailAllowlist: b.emailAllowlist,
|
|
4425
|
+
password: b.password,
|
|
4426
|
+
redactFields: b.redactFields,
|
|
4427
|
+
label: b.label
|
|
4428
|
+
},
|
|
4429
|
+
callerCtx
|
|
4430
|
+
);
|
|
4431
|
+
return { handled: true, response: { status: 201, body: { success: true, data: link, link } } };
|
|
4432
|
+
}
|
|
4433
|
+
if (parts.length === 0 && m === "GET") {
|
|
4434
|
+
const links = await svc.listLinks(
|
|
4435
|
+
{
|
|
4436
|
+
object: typeof query?.object === "string" ? query.object : void 0,
|
|
4437
|
+
recordId: typeof query?.recordId === "string" ? query.recordId : void 0,
|
|
4438
|
+
// Constrain to links the caller created so a guessed
|
|
4439
|
+
// recordId can never enumerate another user's tokens.
|
|
4440
|
+
createdBy: callerCtx.userId,
|
|
4441
|
+
includeRevoked: query?.includeRevoked === "true" || query?.includeRevoked === "1"
|
|
4442
|
+
},
|
|
4443
|
+
callerCtx
|
|
4444
|
+
);
|
|
4445
|
+
return { handled: true, response: { status: 200, body: { success: true, data: links, links } } };
|
|
4446
|
+
}
|
|
4447
|
+
if (parts.length === 1 && m === "DELETE") {
|
|
4448
|
+
await svc.revokeLink(decodeURIComponent(parts[0]), callerCtx);
|
|
4449
|
+
return { handled: true, response: this.success({ ok: true }) };
|
|
4450
|
+
}
|
|
4451
|
+
return { handled: true, response: this.routeNotFound(`/share-links${subPath}`) };
|
|
4452
|
+
} catch (err) {
|
|
4453
|
+
return sendErr(err?.status ?? 500, err?.code ?? "INTERNAL", err?.message ?? "Share link request failed");
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4235
4456
|
/**
|
|
4236
4457
|
* Main Dispatcher Entry Point
|
|
4237
4458
|
* Routes the request to the appropriate handler based on path and precedence
|
|
@@ -4327,6 +4548,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4327
4548
|
if (cleanPath.startsWith("/ai")) {
|
|
4328
4549
|
return this.handleAI(cleanPath, method, body, query, context);
|
|
4329
4550
|
}
|
|
4551
|
+
if (cleanPath === "/share-links" || cleanPath.startsWith("/share-links/")) {
|
|
4552
|
+
return this.handleShareLinks(cleanPath.substring("/share-links".length), method, body, query, context);
|
|
4553
|
+
}
|
|
4330
4554
|
if (cleanPath === "/openapi.json" && method === "GET") {
|
|
4331
4555
|
try {
|
|
4332
4556
|
const metaSvc = await this.resolveService("metadata", context.environmentId);
|