@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 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.newAsyncifiedFunction(method, async (...argHandles) => {
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 proxy = apiAny.object(objectName);
243
- const m = proxy[method];
244
- if (typeof m !== "function") {
245
- throw new SandboxError(`ctx.api.object('${objectName}').${method} not implemented`);
246
- }
247
- const ret = await Promise.resolve(m.apply(proxy, args));
248
- return jsonToHandle(vm, ret);
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
- while (pumps < 1e3) {
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 via {@link QuickJSAsyncContext.newAsyncifiedFunction}
453
- * so they may return Promises (real ObjectQL `find/count/insert/...` are async).
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);