@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.d.cts CHANGED
@@ -1567,6 +1567,26 @@ declare class HttpDispatcher {
1567
1567
  * Resolves the AI service and its built-in route handlers, then dispatches.
1568
1568
  */
1569
1569
  handleAI(subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
1570
+ /**
1571
+ * Share-link capability tokens — "anyone with the link" publication of a
1572
+ * single record (ADR-0047). Mirrors the per-env service-dispatch pattern
1573
+ * used by {@link handleI18n} / {@link handleAI}: the `shareLinks` service
1574
+ * is resolved from the request's environment kernel, so links live in (and
1575
+ * resolve against) the same per-environment database that owns the record.
1576
+ * This branch owns URL parsing and the auth/public split.
1577
+ *
1578
+ * POST /share-links → create a link (authenticated)
1579
+ * GET /share-links?object&recordId → list the caller's links (authenticated)
1580
+ * DELETE /share-links/:idOrToken → revoke (authenticated)
1581
+ * GET /share-links/:token/resolve → resolve token → record (PUBLIC)
1582
+ * GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
1583
+ *
1584
+ * The resolve / messages routes are intentionally public — the token IS
1585
+ * the authorisation. The underlying record is fetched with a SYSTEM
1586
+ * context (per-env RLS is bypassed because the token gates access), and
1587
+ * `redactFields` are stripped before the record leaves the server.
1588
+ */
1589
+ handleShareLinks(subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
1570
1590
  /**
1571
1591
  * Main Dispatcher Entry Point
1572
1592
  * Routes the request to the appropriate handler based on path and precedence
@@ -1876,8 +1896,9 @@ declare class QuickJSScriptRunner implements ScriptRunner {
1876
1896
  * the body declared it; missing methods throw at call-time inside the VM
1877
1897
  * with a clear diagnostic.
1878
1898
  *
1879
- * Host API methods are installed via {@link QuickJSAsyncContext.newAsyncifiedFunction}
1880
- * so they may return Promises (real ObjectQL `find/count/insert/...` are async).
1899
+ * Host API methods are installed as deferred-promise functions (see
1900
+ * {@link installApiMethod}) so they may return Promises (real ObjectQL
1901
+ * `find/count/insert/...` are async) without asyncify's single-unwind limit.
1881
1902
  */
1882
1903
  private installCtx;
1883
1904
  }
package/dist/index.d.ts CHANGED
@@ -1567,6 +1567,26 @@ declare class HttpDispatcher {
1567
1567
  * Resolves the AI service and its built-in route handlers, then dispatches.
1568
1568
  */
1569
1569
  handleAI(subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
1570
+ /**
1571
+ * Share-link capability tokens — "anyone with the link" publication of a
1572
+ * single record (ADR-0047). Mirrors the per-env service-dispatch pattern
1573
+ * used by {@link handleI18n} / {@link handleAI}: the `shareLinks` service
1574
+ * is resolved from the request's environment kernel, so links live in (and
1575
+ * resolve against) the same per-environment database that owns the record.
1576
+ * This branch owns URL parsing and the auth/public split.
1577
+ *
1578
+ * POST /share-links → create a link (authenticated)
1579
+ * GET /share-links?object&recordId → list the caller's links (authenticated)
1580
+ * DELETE /share-links/:idOrToken → revoke (authenticated)
1581
+ * GET /share-links/:token/resolve → resolve token → record (PUBLIC)
1582
+ * GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
1583
+ *
1584
+ * The resolve / messages routes are intentionally public — the token IS
1585
+ * the authorisation. The underlying record is fetched with a SYSTEM
1586
+ * context (per-env RLS is bypassed because the token gates access), and
1587
+ * `redactFields` are stripped before the record leaves the server.
1588
+ */
1589
+ handleShareLinks(subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
1570
1590
  /**
1571
1591
  * Main Dispatcher Entry Point
1572
1592
  * Routes the request to the appropriate handler based on path and precedence
@@ -1876,8 +1896,9 @@ declare class QuickJSScriptRunner implements ScriptRunner {
1876
1896
  * the body declared it; missing methods throw at call-time inside the VM
1877
1897
  * with a clear diagnostic.
1878
1898
  *
1879
- * Host API methods are installed via {@link QuickJSAsyncContext.newAsyncifiedFunction}
1880
- * so they may return Promises (real ObjectQL `find/count/insert/...` are async).
1899
+ * Host API methods are installed as deferred-promise functions (see
1900
+ * {@link installApiMethod}) so they may return Promises (real ObjectQL
1901
+ * `find/count/insert/...` are async) without asyncify's single-unwind limit.
1881
1902
  */
1882
1903
  private installCtx;
1883
1904
  }
package/dist/index.js CHANGED
@@ -206,7 +206,7 @@ import {
206
206
  newAsyncContext
207
207
  } from "quickjs-emscripten";
208
208
  function installApiMethod(vm, parent, method, objectName, ctx, caps, required, origin) {
209
- const fn = vm.newAsyncifiedFunction(method, async (...argHandles) => {
209
+ const fn = vm.newFunction(method, (...argHandles) => {
210
210
  if (!caps.has(required)) {
211
211
  throw new SandboxError(
212
212
  `capability '${required}' not granted to ${origin.kind} '${origin.name}' (called ctx.api.object('${objectName}').${method})`
@@ -217,13 +217,27 @@ function installApiMethod(vm, parent, method, objectName, ctx, caps, required, o
217
217
  throw new SandboxError(`ctx.api unavailable in ${origin.kind} '${origin.name}'`);
218
218
  }
219
219
  const args = argHandles.map((h) => vm.dump(h));
220
- const proxy = apiAny.object(objectName);
221
- const m = proxy[method];
222
- if (typeof m !== "function") {
223
- throw new SandboxError(`ctx.api.object('${objectName}').${method} not implemented`);
224
- }
225
- const ret = await Promise.resolve(m.apply(proxy, args));
226
- return jsonToHandle(vm, ret);
220
+ const deferred = vm.newPromise();
221
+ void (async () => {
222
+ try {
223
+ const proxy = apiAny.object(objectName);
224
+ const m = proxy[method];
225
+ if (typeof m !== "function") {
226
+ throw new SandboxError(`ctx.api.object('${objectName}').${method} not implemented`);
227
+ }
228
+ const ret = await Promise.resolve(m.apply(proxy, args));
229
+ if (!vm.alive) return;
230
+ const h = jsonToHandle(vm, ret);
231
+ deferred.resolve(h);
232
+ h.dispose();
233
+ } catch (err) {
234
+ if (!vm.alive) return;
235
+ const errH = err instanceof Error ? vm.newError({ name: err.name || "Error", message: err.message }) : vm.newError({ name: "Error", message: String(err) });
236
+ deferred.reject(errH);
237
+ errH.dispose();
238
+ }
239
+ })();
240
+ return deferred.handle;
227
241
  });
228
242
  vm.setProp(parent, method, fn);
229
243
  fn.dispose();
@@ -385,7 +399,7 @@ var init_quickjs_runner = __esm({
385
399
  }
386
400
  evalRes.value.dispose();
387
401
  let pumps = 0;
388
- while (pumps < 1e3) {
402
+ for (; ; ) {
389
403
  await new Promise((resolve) => setImmediate(resolve));
390
404
  const pending = runtime.executePendingJobs();
391
405
  if (pending.error) {
@@ -409,14 +423,11 @@ var init_quickjs_runner = __esm({
409
423
  }
410
424
  if (Date.now() > deadline) {
411
425
  throw new SandboxError(
412
- `${args.origin.kind} '${args.origin.name}' exceeded timeout of ${args.timeoutMs}ms`
426
+ `${args.origin.kind} '${args.origin.name}' exceeded timeout of ${args.timeoutMs}ms (after ${pumps} pump iterations)`
413
427
  );
414
428
  }
415
429
  pumps++;
416
430
  }
417
- throw new SandboxError(
418
- `${args.origin.kind} '${args.origin.name}' did not resolve after ${pumps} pump iterations`
419
- );
420
431
  } finally {
421
432
  vm.dispose();
422
433
  }
@@ -426,8 +437,9 @@ var init_quickjs_runner = __esm({
426
437
  * the body declared it; missing methods throw at call-time inside the VM
427
438
  * with a clear diagnostic.
428
439
  *
429
- * Host API methods are installed via {@link QuickJSAsyncContext.newAsyncifiedFunction}
430
- * so they may return Promises (real ObjectQL `find/count/insert/...` are async).
440
+ * Host API methods are installed as deferred-promise functions (see
441
+ * {@link installApiMethod}) so they may return Promises (real ObjectQL
442
+ * `find/count/insert/...` are async) without asyncify's single-unwind limit.
431
443
  */
432
444
  installCtx(vm, ctx, caps, origin) {
433
445
  setGlobalJson(vm, "__input", ctx.input);
@@ -1939,6 +1951,43 @@ async function tryFind(ql, object, where, limit = 100) {
1939
1951
  return [];
1940
1952
  }
1941
1953
  }
1954
+ function isValidTimeZone(tz) {
1955
+ try {
1956
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
1957
+ return true;
1958
+ } catch {
1959
+ return false;
1960
+ }
1961
+ }
1962
+ function coerceTimeZone(value) {
1963
+ const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
1964
+ return s && isValidTimeZone(s) ? s : void 0;
1965
+ }
1966
+ function coerceLocale(value) {
1967
+ const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
1968
+ return s || void 0;
1969
+ }
1970
+ async function resolveLocalization(opts, ql, sctx) {
1971
+ try {
1972
+ const settings = await opts.getService("settings");
1973
+ if (settings && typeof settings.get === "function") {
1974
+ const [tzRes, localeRes] = await Promise.all([
1975
+ settings.get("localization", "timezone", sctx).catch(() => void 0),
1976
+ settings.get("localization", "locale", sctx).catch(() => void 0)
1977
+ ]);
1978
+ const tz = coerceTimeZone(tzRes?.value);
1979
+ const locale = coerceLocale(localeRes?.value);
1980
+ if (tz || locale) return { timezone: tz ?? "UTC", locale: locale ?? "en-US" };
1981
+ }
1982
+ } catch {
1983
+ }
1984
+ const tzRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "timezone", scope: "tenant" }, 1);
1985
+ const localeRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "locale", scope: "tenant" }, 1);
1986
+ return {
1987
+ timezone: coerceTimeZone(tzRows[0]?.value) ?? "UTC",
1988
+ locale: coerceLocale(localeRows[0]?.value) ?? "en-US"
1989
+ };
1990
+ }
1942
1991
  async function resolveExecutionContext(opts) {
1943
1992
  const headers = opts.request?.headers;
1944
1993
  const ctx = {
@@ -2069,6 +2118,9 @@ async function resolveExecutionContext(opts) {
2069
2118
  ctx.tabPermissions = mergedTabs;
2070
2119
  }
2071
2120
  }
2121
+ const localization = await resolveLocalization(opts, ql, { tenantId, userId });
2122
+ ctx.timezone = localization.timezone;
2123
+ ctx.locale = localization.locale;
2072
2124
  return ctx;
2073
2125
  }
2074
2126
  function isPermissionDeniedError(e) {
@@ -2568,6 +2620,7 @@ var _HttpDispatcher = class _HttpDispatcher {
2568
2620
  if (!this.enforceMembership) return null;
2569
2621
  const skipPaths = ["/auth", "/cloud", "/health", "/discovery"];
2570
2622
  if (skipPaths.some((p) => path.startsWith(p))) return null;
2623
+ if (/(^|\/)share-links\/[^/]+\/(resolve|messages)$/.test(path)) return null;
2571
2624
  const environmentId = context.environmentId;
2572
2625
  if (!environmentId) return null;
2573
2626
  if (environmentId === _HttpDispatcher.SYSTEM_ENVIRONMENT_ID) return null;
@@ -4164,6 +4217,174 @@ var _HttpDispatcher = class _HttpDispatcher {
4164
4217
  response: this.routeNotFound(subPath)
4165
4218
  };
4166
4219
  }
4220
+ /**
4221
+ * Share-link capability tokens — "anyone with the link" publication of a
4222
+ * single record (ADR-0047). Mirrors the per-env service-dispatch pattern
4223
+ * used by {@link handleI18n} / {@link handleAI}: the `shareLinks` service
4224
+ * is resolved from the request's environment kernel, so links live in (and
4225
+ * resolve against) the same per-environment database that owns the record.
4226
+ * This branch owns URL parsing and the auth/public split.
4227
+ *
4228
+ * POST /share-links → create a link (authenticated)
4229
+ * GET /share-links?object&recordId → list the caller's links (authenticated)
4230
+ * DELETE /share-links/:idOrToken → revoke (authenticated)
4231
+ * GET /share-links/:token/resolve → resolve token → record (PUBLIC)
4232
+ * GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
4233
+ *
4234
+ * The resolve / messages routes are intentionally public — the token IS
4235
+ * the authorisation. The underlying record is fetched with a SYSTEM
4236
+ * context (per-env RLS is bypassed because the token gates access), and
4237
+ * `redactFields` are stripped before the record leaves the server.
4238
+ */
4239
+ async handleShareLinks(subPath, method, body, query, context) {
4240
+ const svc = await this.resolveService("shareLinks", context.environmentId);
4241
+ if (!svc) {
4242
+ return { handled: true, response: this.error("Sharing is not configured for this environment", 501) };
4243
+ }
4244
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
4245
+ const m = method.toUpperCase();
4246
+ const parts = subPath.replace(/^\/+/, "").split("/").filter(Boolean);
4247
+ const ec = context.executionContext;
4248
+ const callerCtx = { userId: ec?.userId, tenantId: ec?.tenantId };
4249
+ const headerOf = (name) => {
4250
+ const h = context.request?.headers;
4251
+ if (!h) return void 0;
4252
+ const v = typeof h.get === "function" ? h.get(name) : h[name] ?? h[name.toLowerCase()];
4253
+ return Array.isArray(v) ? v[0] : v ?? void 0;
4254
+ };
4255
+ const sendErr = (status, code, msg) => ({
4256
+ handled: true,
4257
+ response: this.error(msg, status, { code })
4258
+ });
4259
+ const getEngine = async () => {
4260
+ try {
4261
+ const k = this.kernel;
4262
+ const e = typeof k?.getServiceAsync === "function" ? await k.getServiceAsync("objectql") : k?.getService?.("objectql");
4263
+ if (e) return e;
4264
+ } catch {
4265
+ }
4266
+ return this.resolveService("objectql", context.environmentId);
4267
+ };
4268
+ const asArray = (rows) => Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : [];
4269
+ const applyRedaction = (record, redactFields) => {
4270
+ if (!record || typeof record !== "object" || redactFields.length === 0) return record;
4271
+ const out = {};
4272
+ for (const [k, v] of Object.entries(record)) {
4273
+ if (redactFields.includes(k)) continue;
4274
+ out[k] = v;
4275
+ }
4276
+ return out;
4277
+ };
4278
+ try {
4279
+ if (parts.length === 2 && parts[1] === "resolve" && m === "GET") {
4280
+ const token = decodeURIComponent(parts[0]);
4281
+ const signedInUserId = ec?.userId;
4282
+ const recipientEmail = typeof query?.email === "string" ? query.email : void 0;
4283
+ const providedPassword = typeof query?.password === "string" ? query.password : headerOf("x-share-password");
4284
+ const resolved = await svc.resolveToken(token, { signedInUserId, recipientEmail, providedPassword });
4285
+ if (!resolved) {
4286
+ const engine2 = await getEngine();
4287
+ const probe = engine2 ? asArray(await engine2.find("sys_share_link", { where: { token }, limit: 1, context: SYSTEM_CTX })) : [];
4288
+ const row = probe[0] ?? null;
4289
+ const live = row && !row.revoked_at && (!row.expires_at || Date.parse(row.expires_at) > Date.now());
4290
+ if (live && row.password_hash) {
4291
+ return sendErr(
4292
+ 401,
4293
+ providedPassword ? "WRONG_PASSWORD" : "NEEDS_PASSWORD",
4294
+ providedPassword ? "Incorrect password" : "This link requires a password"
4295
+ );
4296
+ }
4297
+ if (live && row.audience === "signed_in" && !signedInUserId) {
4298
+ return sendErr(401, "SIGN_IN_REQUIRED", "Please sign in to view this link");
4299
+ }
4300
+ if (row && (row.revoked_at || row.expires_at && Date.parse(row.expires_at) <= Date.now())) {
4301
+ return sendErr(410, "EXPIRED_OR_REVOKED", "Share link has expired or been revoked");
4302
+ }
4303
+ return sendErr(404, "INVALID_OR_EXPIRED", "Share link is invalid, expired, or revoked");
4304
+ }
4305
+ const engine = await getEngine();
4306
+ const rows = engine ? asArray(await engine.find(resolved.link.object_name, { where: { id: resolved.link.record_id }, limit: 1, context: SYSTEM_CTX })) : [];
4307
+ const record = rows[0] ?? null;
4308
+ if (!record) return sendErr(410, "RECORD_GONE", "The shared record no longer exists");
4309
+ return {
4310
+ handled: true,
4311
+ response: this.success({
4312
+ record: applyRedaction(record, resolved.redactFields),
4313
+ link: {
4314
+ id: resolved.link.id,
4315
+ token: resolved.link.token,
4316
+ object_name: resolved.link.object_name,
4317
+ record_id: resolved.link.record_id,
4318
+ permission: resolved.link.permission,
4319
+ audience: resolved.link.audience,
4320
+ expires_at: resolved.link.expires_at,
4321
+ label: resolved.link.label,
4322
+ created_at: resolved.link.created_at
4323
+ },
4324
+ redactFields: resolved.redactFields
4325
+ })
4326
+ };
4327
+ }
4328
+ if (parts.length === 2 && parts[1] === "messages" && m === "GET") {
4329
+ const token = decodeURIComponent(parts[0]);
4330
+ const providedPassword = typeof query?.password === "string" ? query.password : headerOf("x-share-password");
4331
+ const resolved = await svc.resolveToken(token, { signedInUserId: ec?.userId, providedPassword });
4332
+ if (!resolved) return sendErr(404, "NOT_FOUND", "Share link not found");
4333
+ if (resolved.link.object_name !== "ai_conversations") {
4334
+ return sendErr(400, "UNSUPPORTED", "This share link does not expose messages");
4335
+ }
4336
+ const engine = await getEngine();
4337
+ const rows = engine ? asArray(await engine.find("ai_messages", {
4338
+ where: { conversation_id: resolved.link.record_id },
4339
+ sort: [{ field: "created_at", order: "asc" }],
4340
+ limit: 500,
4341
+ context: SYSTEM_CTX
4342
+ })) : [];
4343
+ return { handled: true, response: this.success(rows) };
4344
+ }
4345
+ if (!callerCtx.userId) return sendErr(401, "UNAUTHENTICATED", "Sign in to manage share links");
4346
+ if (parts.length === 0 && m === "POST") {
4347
+ const b = body ?? {};
4348
+ if (!b.object || !b.recordId) return sendErr(400, "VALIDATION_FAILED", "object and recordId are required");
4349
+ const link = await svc.createLink(
4350
+ {
4351
+ object: b.object,
4352
+ recordId: b.recordId,
4353
+ permission: b.permission,
4354
+ audience: b.audience,
4355
+ expiresAt: b.expiresAt ?? null,
4356
+ emailAllowlist: b.emailAllowlist,
4357
+ password: b.password,
4358
+ redactFields: b.redactFields,
4359
+ label: b.label
4360
+ },
4361
+ callerCtx
4362
+ );
4363
+ return { handled: true, response: { status: 201, body: { success: true, data: link, link } } };
4364
+ }
4365
+ if (parts.length === 0 && m === "GET") {
4366
+ const links = await svc.listLinks(
4367
+ {
4368
+ object: typeof query?.object === "string" ? query.object : void 0,
4369
+ recordId: typeof query?.recordId === "string" ? query.recordId : void 0,
4370
+ // Constrain to links the caller created so a guessed
4371
+ // recordId can never enumerate another user's tokens.
4372
+ createdBy: callerCtx.userId,
4373
+ includeRevoked: query?.includeRevoked === "true" || query?.includeRevoked === "1"
4374
+ },
4375
+ callerCtx
4376
+ );
4377
+ return { handled: true, response: { status: 200, body: { success: true, data: links, links } } };
4378
+ }
4379
+ if (parts.length === 1 && m === "DELETE") {
4380
+ await svc.revokeLink(decodeURIComponent(parts[0]), callerCtx);
4381
+ return { handled: true, response: this.success({ ok: true }) };
4382
+ }
4383
+ return { handled: true, response: this.routeNotFound(`/share-links${subPath}`) };
4384
+ } catch (err) {
4385
+ return sendErr(err?.status ?? 500, err?.code ?? "INTERNAL", err?.message ?? "Share link request failed");
4386
+ }
4387
+ }
4167
4388
  /**
4168
4389
  * Main Dispatcher Entry Point
4169
4390
  * Routes the request to the appropriate handler based on path and precedence
@@ -4259,6 +4480,9 @@ var _HttpDispatcher = class _HttpDispatcher {
4259
4480
  if (cleanPath.startsWith("/ai")) {
4260
4481
  return this.handleAI(cleanPath, method, body, query, context);
4261
4482
  }
4483
+ if (cleanPath === "/share-links" || cleanPath.startsWith("/share-links/")) {
4484
+ return this.handleShareLinks(cleanPath.substring("/share-links".length), method, body, query, context);
4485
+ }
4262
4486
  if (cleanPath === "/openapi.json" && method === "GET") {
4263
4487
  try {
4264
4488
  const metaSvc = await this.resolveService("metadata", context.environmentId);