@k2b/cloud 0.29.0 → 0.30.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.
Files changed (92) hide show
  1. package/package.json +5 -3
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +203 -185
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
  17. package/src/ai/chat/message-utils.ts +3 -0
  18. package/src/ai/chat/messages.ts +12 -26
  19. package/src/ai/chat/tool-disclosure.tsx +21 -14
  20. package/src/ai/chat/tool-groups.ts +64 -37
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +4 -4
  23. package/src/ai/chat/turn-view.tsx +141 -69
  24. package/src/ai/code-runtime-tools.ts +59 -44
  25. package/src/ai/default-tool-contracts.ts +198 -0
  26. package/src/ai/default-tools.ts +25 -1
  27. package/src/ai/executor.ts +19 -21
  28. package/src/ai/index.ts +9 -1
  29. package/src/ai/migrate.ts +59 -12
  30. package/src/ai/protocol.ts +9 -4
  31. package/src/ai/runtime-tools.ts +1 -0
  32. package/src/ai/skill-content.ts +54 -0
  33. package/src/ai/skills-routes.ts +22 -13
  34. package/src/ai/skills.ts +177 -231
  35. package/src/ai/store.ts +20 -13
  36. package/src/ai/tool-sentences.ts +38 -0
  37. package/src/ai/types.ts +10 -2
  38. package/src/api/admin-ai-skills.ts +35 -16
  39. package/src/api/admin-outgoing-mail.ts +18 -0
  40. package/src/api/fanout.ts +128 -0
  41. package/src/api/help.ts +8 -3
  42. package/src/api/mcp.ts +4 -1
  43. package/src/api/search.ts +31 -106
  44. package/src/api/widgets.ts +259 -100
  45. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  46. package/src/browser/ndjson.ts +34 -0
  47. package/src/browser/reload.ts +46 -0
  48. package/src/browser/search-stream.ts +4 -26
  49. package/src/browser/widgets.ts +28 -0
  50. package/src/capabilities/server.ts +20 -2
  51. package/src/cli/access.ts +48 -14
  52. package/src/cli/admin/ai-skills.ts +59 -39
  53. package/src/cli/admin/outgoing-mail.ts +30 -2
  54. package/src/contracts/announcement-cookie.ts +62 -0
  55. package/src/contracts/announcements.ts +14 -52
  56. package/src/contracts/capabilities.ts +24 -0
  57. package/src/contracts/outgoing-mail.ts +56 -11
  58. package/src/contracts/pwa-paths.ts +7 -2
  59. package/src/contracts/pwa.ts +2 -7
  60. package/src/contracts/registry.ts +1 -0
  61. package/src/contracts/shared.ts +5 -0
  62. package/src/contracts/widgets.ts +20 -0
  63. package/src/index.ts +1 -0
  64. package/src/server/help.ts +8 -1
  65. package/src/services/accounts/users.ts +15 -0
  66. package/src/services/help/index.ts +18 -5
  67. package/src/services/help/types.ts +3 -2
  68. package/src/services/identity/constants.ts +2 -0
  69. package/src/services/identity/key-ring.ts +123 -42
  70. package/src/services/identity/runtime-config.ts +13 -2
  71. package/src/services/outbox.ts +24 -9
  72. package/src/services/outgoing-mail/index.ts +33 -5
  73. package/src/services/outgoing-mail/messages.ts +60 -19
  74. package/src/services/outgoing-mail/store.ts +63 -4
  75. package/src/shared/network-address.ts +1 -0
  76. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  77. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  78. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  79. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  80. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  81. package/src/ssr/PwaRuntime.island.tsx +1 -1
  82. package/src/ssr/app-navigation.ts +5 -7
  83. package/src/ssr/global-search-host.tsx +55 -0
  84. package/src/ssr/help.ts +3 -3
  85. package/src/ssr/rail-context.ts +4 -3
  86. package/src/ssr/runtime.ts +14 -1
  87. package/src/styles/effects.css +142 -0
  88. package/src/styles/utilities-markdown-table.css +5 -1
  89. package/src/ai/code-mode-skill.ts +0 -114
  90. package/src/ai/data-analysis-skill.ts +0 -10
  91. package/src/ai/grids-skill.ts +0 -158
  92. package/src/ai/skill-seeds.ts +0 -560
@@ -88,6 +88,7 @@ export type AppRegistryEntry = {
88
88
  adminNav?: AppAdminNavigationGroup[];
89
89
  capabilities?: AppRegistryCapabilitySummary;
90
90
  help?: AppRegistryHelpSummary;
91
+ skills?: { manifestHash: string };
91
92
  legalLinks?: AppRegistryLegalLink[];
92
93
  searchLinks?: readonly AppSearchLink[];
93
94
  /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
@@ -329,6 +329,11 @@ export const AccessEntrySchema = z.object({
329
329
  avatarHash: z.string().nullable().optional(),
330
330
  /** Kind of a `service_account` principal; presentation only, never authorization. */
331
331
  serviceAccountKind: ServiceAccountKindSchema.optional(),
332
+ /**
333
+ * A narrower part of the resource the grant covers, defined by the application, such as Mail's
334
+ * `assigned` conversations. Absent for a grant on the whole resource.
335
+ */
336
+ scope: z.string().min(1).max(64).optional(),
332
337
  });
333
338
  export type AccessEntry = z.infer<typeof AccessEntrySchema>;
334
339
 
@@ -181,3 +181,23 @@ export const WidgetResponseSchema = z
181
181
  })
182
182
  .strip();
183
183
  export type WidgetResponse = z.infer<typeof WidgetResponseSchema>;
184
+
185
+ /** A widget's stable key on the dashboard and in the widget stream: `<appId>/<widgetId>`. */
186
+ export const widgetKey = (appId: string, widgetId: string): string => `${appId}/${widgetId}`;
187
+
188
+ /**
189
+ * How one widget answered in the widget stream: `ok` carries its response, `empty` is a `204`, `forbidden` a `403`,
190
+ * `timeout` means it did not answer within its own budget, and `error` covers every other failure.
191
+ */
192
+ export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
193
+
194
+ /**
195
+ * One line of `GET /api/widgets/v1` (`application/x-ndjson`): `start` names the widgets Core asks, in registry
196
+ * order; one `widget` line follows per widget in the order they finish; `done` closes the stream. Every widget named
197
+ * in `start` gets exactly one line.
198
+ */
199
+ export type WidgetStreamLine =
200
+ | { type: "start"; widgets: string[] }
201
+ | { type: "widget"; key: string; status: "ok"; widget: WidgetResponse; ms: number }
202
+ | { type: "widget"; key: string; status: Exclude<WidgetStreamStatus, "ok">; ms: number }
203
+ | { type: "done"; status: "complete" | "partial" };
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  export type { RuntimeCompatibilityIssue } from "./_internal/runtime-compatibility";
36
36
  export { assessRuntimeCompatibility } from "./_internal/runtime-compatibility";
37
37
  export { buildRuntimeFromRegistry } from "./_internal/runtime-context";
38
+ export { type AppSkillDefinition, skill } from "./ai/app-skills";
38
39
  export { defineCapabilities } from "./contracts/capabilities";
39
40
  export type {
40
41
  AnyBoundNotificationDefinition,
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
3
3
  import { z } from "zod";
4
4
  import { type HelpDocumentManifest, type HelpDocumentPayload, type HelpSearchPayload, helpLocaleChain } from "../shared/help";
5
5
  import { markdownToPlainText, renderHelpMarkdown } from "../shared/markdown";
6
+ import { auth } from "./middleware/auth";
6
7
 
7
8
  const metadataSchema = z.object({
8
9
  id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
@@ -133,6 +134,9 @@ export const defineHelp = (
133
134
  * Define one app-owned help corpus. The explicit source list is deliberate:
134
135
  * IDs, ordering and ownership stay visible in code; no filesystem scanning or
135
136
  * build-time convention is required.
137
+ *
138
+ * @deprecated Declare Help with `defineHelp()` and pass it to `app.start({ help })`. This router only
139
+ * requires sign-in; it does not apply the app's Help visibility, so the mounting app's routes must guard it.
136
140
  */
137
141
  export const defineHelpCollection = (options: { basePath: string; sources: readonly string[] }): HelpCollection => {
138
142
  const basePath = options.basePath.replace(/\/$/, "");
@@ -151,7 +155,10 @@ export const defineHelpCollection = (options: { basePath: string; sources: reado
151
155
  url: `${basePath}/${encodeURIComponent(id)}`,
152
156
  }));
153
157
 
154
- const router = new Hono()
158
+ // Help is for signed-in people wherever an app mounts this router; its own route policy may narrow it further.
159
+ const router = new Hono();
160
+ router.use(auth.requireRole("authenticated"), auth.requireUser());
161
+ router
155
162
  .get("/search", (context) => {
156
163
  const query = context.req.query("q")?.trim().toLocaleLowerCase().slice(0, 200) ?? "";
157
164
  const payload: HelpSearchPayload = {
@@ -223,6 +223,21 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
223
223
  return buildUserMutationTarget(rows[0]!);
224
224
  };
225
225
 
226
+ /** Audience roles for a resolved access subject, including delegated users and group managers. */
227
+ export const getRoles = async (userId: string): Promise<Role[]> => {
228
+ const { groupsAdmin } = await getFreeIpaConfig();
229
+ const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
230
+ CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
231
+ SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
232
+ AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
233
+ ) END AS effective_admin,
234
+ ARRAY(${managedGroupsNamesSubquery(sql`u.id`)}) AS manages
235
+ FROM auth.users u WHERE u.id=${userId}::uuid`;
236
+ if (!row) return [];
237
+ const { provider, profile } = resolveProviderProfile(row);
238
+ return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
239
+ };
240
+
226
241
  export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
227
242
  const { groupsAdmin } = await getFreeIpaConfig();
228
243
  const rows = await sql<DbRow[]>`
@@ -1,6 +1,8 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import { appAudienceRoles, hasAnyAppRole } from "../../_internal/app-roles";
2
3
  import { getApp, listApps } from "../../_internal/registry";
3
4
  import type { AppRegistryEntry } from "../../contracts/registry";
5
+ import type { RequestActor } from "../../contracts/shared";
4
6
  import { resolveAppPresentations } from "../../shared/app-presentation";
5
7
  import { helpLocaleChain } from "../../shared/help";
6
8
  import { logger } from "../logging";
@@ -12,8 +14,20 @@ export type { HelpArticle, HelpMatch, HelpMetadata, HelpReader, HelpReaderFactor
12
14
 
13
15
  const log = logger("help");
14
16
 
17
+ /**
18
+ * Help follows the app's declared visibility: it needs a user (the person, or the user an API credential acts
19
+ * for), the app's navigation roles limit it, and an app reached only through the admin area is for admins.
20
+ * The app's routes still authorize the app itself.
21
+ */
22
+ export const canReadAppHelp = (app: Pick<AppRegistryEntry, "nav">, viewer: RequestActor | undefined): boolean => {
23
+ const user = viewer?.kind === "user" ? viewer.user : (viewer?.delegatedUser ?? undefined);
24
+ return !!user && hasAnyAppRole(user, appAudienceRoles(app.nav));
25
+ };
26
+
27
+ /** Reads Help in one language for one viewer; apps the viewer may not see do not exist for this reader. */
15
28
  export const createHelpReader = (
16
29
  locale: string,
30
+ viewer: RequestActor | undefined,
17
31
  dependencies: {
18
32
  db?: SQL;
19
33
  listApps?: () => Promise<AppRegistryEntry[]>;
@@ -21,11 +35,10 @@ export const createHelpReader = (
21
35
  } = {},
22
36
  ): HelpReader => {
23
37
  const db = dependencies.db ?? sql;
38
+ const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry => app !== null && canReadAppHelp(app, viewer);
24
39
  const select = async (appId?: string): Promise<HelpSelection[]> => {
25
- const apps = appId
26
- ? [await (dependencies.getApp ?? getApp)(appId)].filter((app): app is AppRegistryEntry => app !== null)
27
- : await (dependencies.listApps ?? listApps)();
28
- return resolveAppPresentations(apps, locale).flatMap((app) =>
40
+ const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
41
+ return resolveAppPresentations(apps.filter(visible), locale).flatMap((app) =>
29
42
  app.help
30
43
  ? [
31
44
  {
@@ -41,7 +54,7 @@ export const createHelpReader = (
41
54
  return {
42
55
  async manifest(appId) {
43
56
  const app = await (dependencies.getApp ?? getApp)(appId);
44
- if (!app?.help) return null;
57
+ if (!visible(app) || !app.help) return null;
45
58
  const [corpus] = await db`SELECT 1 FROM help.corpora WHERE app_id=${app.id} AND manifest_hash=${app.help.manifestHash}`;
46
59
  if (!corpus) return null;
47
60
  const rows = await queryHelp(
@@ -1,4 +1,5 @@
1
1
  import type { AppRegistryHelpSummary } from "../../contracts/registry";
2
+ import type { RequestActor } from "../../contracts/shared";
2
3
  import type { HelpDocumentManifest } from "../../shared/help";
3
4
 
4
5
  export type HelpDocument = {
@@ -36,11 +37,11 @@ export type HelpSearchInput = { query: string; appId?: string; limit?: number };
36
37
  export type HelpReadInput = { appId: string; documentId: string };
37
38
  export type HelpManifestResult = AppRegistryHelpSummary & { locale: string; documents: readonly HelpDocumentManifest[] };
38
39
 
39
- /** One request's language; operations resolve the current published app versions. */
40
+ /** One request's language and viewer; operations resolve the current published versions of the apps the viewer may see. */
40
41
  export type HelpReader = {
41
42
  manifest(appId: string): Promise<HelpManifestResult | null>;
42
43
  list(cursor?: string): Promise<HelpMetadata[]>;
43
44
  search(input: HelpSearchInput): Promise<HelpMatch[]>;
44
45
  read(input: HelpReadInput): Promise<HelpArticle | null>;
45
46
  };
46
- export type HelpReaderFactory = (locale: string) => HelpReader;
47
+ export type HelpReaderFactory = (locale: string, viewer: RequestActor | undefined) => HelpReader;
@@ -13,6 +13,8 @@ export const INVOCATION_CLOCK_TOLERANCE_SECONDS = 2;
13
13
  export const IDENTITY_JWKS_MAX_AGE_SECONDS = 5 * 60;
14
14
  export const IDENTITY_ACTIVATION_LEAD_MS = 10 * 60_000;
15
15
  export const IDENTITY_SIGNING_CACHE_MS = 60_000;
16
+ /** Bounds a shared signer refresh or identity settings load; matches the default issuance deadline. */
17
+ export const IDENTITY_REFRESH_TIMEOUT_MS = 30_000;
16
18
  export const IDENTITY_ROTATION_AGE_MS = 30 * 24 * 60 * 60_000;
17
19
  export const IDENTITY_ROLLOUT_MARGIN_MS = 10 * 60_000;
18
20
  export const IDENTITY_MAX_COMPACT_TOKEN_BYTES = 4 * 1024;
@@ -6,6 +6,7 @@ import {
6
6
  CLOUD_IDENTITY_ALGORITHM,
7
7
  IDENTITY_ACTIVATION_LEAD_MS,
8
8
  IDENTITY_CLOCK_TOLERANCE_SECONDS,
9
+ IDENTITY_REFRESH_TIMEOUT_MS,
9
10
  IDENTITY_ROLLOUT_MARGIN_MS,
10
11
  IDENTITY_ROTATION_AGE_MS,
11
12
  IDENTITY_SIGNING_CACHE_MS,
@@ -76,6 +77,38 @@ const date = (value: Date | string): Date => (value instanceof Date ? value : ne
76
77
  const publicJwk = (value: JWK | string): JWK => (typeof value === "string" ? (JSON.parse(value) as JWK) : value);
77
78
  const serializedSize = (value: unknown): number => encoder.encode(JSON.stringify(value)).byteLength;
78
79
 
80
+ const formatDuration = (ms: number): string => (ms % 1_000 === 0 ? `${ms / 1_000} s` : `${ms} ms`);
81
+
82
+ /**
83
+ * Stops waiting for `work` once `signal` aborts. The work itself keeps running
84
+ * (a pooled query or a shared refresh cannot be cancelled from here), but the
85
+ * caller is released at its deadline instead of whenever the stall ends.
86
+ */
87
+ const untilAborted = <V>(work: Promise<V>, signal: AbortSignal): Promise<V> => {
88
+ if (signal.aborted) {
89
+ work.catch(() => undefined);
90
+ return Promise.reject(signal.reason);
91
+ }
92
+ return new Promise<V>((resolve, reject) => {
93
+ const abort = () => reject(signal.reason);
94
+ signal.addEventListener("abort", abort, { once: true });
95
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
96
+ });
97
+ };
98
+
99
+ const timeoutSignal = (ms: number, reason: () => DOMException): { signal: AbortSignal; clear: () => void } => {
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(reason()), ms);
102
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
103
+ };
104
+
105
+ /**
106
+ * A JS deadline alone cannot cancel a blocked PostgreSQL query. Bound each
107
+ * statement (including lock waits) on this connection, transaction-local.
108
+ */
109
+ const boundStatements = (db: typeof sql, deadline: number) =>
110
+ db`SELECT set_config('statement_timeout', ${`${Math.max(1, Math.floor(deadline - Date.now()))}ms`}, true)`;
111
+
79
112
  const assertRsaJwk = (value: JWK, privateRequired: boolean): void => {
80
113
  if (value.kty !== "RSA" || typeof value.n !== "string" || typeof value.e !== "string") {
81
114
  throw new Error("Cloud identity key is not an RSA JWK");
@@ -156,8 +189,8 @@ const importRow = async (row: SigningKeyRow, keys: IdentityKeyEncryptionConfig):
156
189
  return { kid: row.kid, key, signUntil: date(row.sign_until) };
157
190
  };
158
191
 
159
- const selectRows = async (purpose: SigningKeyPurpose): Promise<SigningKeyRow[]> =>
160
- sql<SigningKeyRow[]>`
192
+ const selectRows = async (db: typeof sql, purpose: SigningKeyPurpose): Promise<SigningKeyRow[]> =>
193
+ db<SigningKeyRow[]>`
161
194
  SELECT id, purpose, state, kid, alg, public_jwk, encrypted_private_jwk, encryption_key_id,
162
195
  created_at, activate_at, activated_at, sign_until, retired_at, verify_until, revoked_at
163
196
  FROM auth.signing_keys
@@ -188,9 +221,16 @@ const insertKey = async (
188
221
  `;
189
222
  };
190
223
 
191
- const maintainPurpose = async (purpose: SigningKeyPurpose): Promise<SigningKeyRow> => {
224
+ /**
225
+ * Every statement ends by `deadline`, so a refresh that its callers already
226
+ * gave up on does not keep holding a pooled connection.
227
+ */
228
+ const maintainPurpose = async (purpose: SigningKeyPurpose, deadline: number): Promise<SigningKeyRow> => {
192
229
  const keys = await readIdentityKeyEncryptionConfig();
193
- const before = await selectRows(purpose);
230
+ const before = await sql.begin(async (tx) => {
231
+ await boundStatements(tx, deadline);
232
+ return selectRows(tx, purpose);
233
+ });
194
234
  const beforeActive = before.find((row) => row.state === "active");
195
235
  const beforePending = before.find((row) => row.state === "pending");
196
236
  const nowMs = Date.now();
@@ -205,6 +245,8 @@ const maintainPurpose = async (purpose: SigningKeyPurpose): Promise<SigningKeyRo
205
245
  ? await generateEncryptedKey(keys)
206
246
  : null;
207
247
  const active = await sql.begin(async (tx) => {
248
+ // Another replica's stuck rotation must not hold this refresh forever.
249
+ await boundStatements(tx, deadline);
208
250
  await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`cloud:identity-keyring:${purpose}`}, 0))`;
209
251
  const rows = await tx<SigningKeyRow[]>`
210
252
  SELECT id, purpose, state, kid, alg, public_jwk, encrypted_private_jwk, encryption_key_id,
@@ -289,10 +331,24 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
289
331
  return signerRefreshes.run(
290
332
  purpose,
291
333
  async () => {
334
+ // Every caller joins this refresh. Bound it, so one hung connection
335
+ // cannot hold back every later issuance until TCP gives up.
336
+ const deadline = Date.now() + IDENTITY_REFRESH_TIMEOUT_MS;
337
+ const bound = timeoutSignal(
338
+ IDENTITY_REFRESH_TIMEOUT_MS,
339
+ () =>
340
+ new DOMException(
341
+ `Cloud identity signer refresh did not finish within ${formatDuration(IDENTITY_REFRESH_TIMEOUT_MS)}`,
342
+ "TimeoutError",
343
+ ),
344
+ );
292
345
  try {
293
- const keys = await readIdentityKeyEncryptionConfig();
294
- const row = await maintainPurpose(purpose);
295
- const signer = await importRow(row, keys);
346
+ const refresh = async () => {
347
+ const keys = await readIdentityKeyEncryptionConfig();
348
+ const row = await maintainPurpose(purpose, deadline);
349
+ return importRow(row, keys);
350
+ };
351
+ const signer = await untilAborted(refresh(), bound.signal);
296
352
  if (signer.signUntil.getTime() <= now) throw new Error(`Active ${purpose} signing key is past sign_until`);
297
353
  return signer;
298
354
  } catch (error) {
@@ -300,6 +356,8 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
300
356
  log.error("Cloud identity signer refresh failed", { purpose, error: error instanceof Error ? error.message : String(error) });
301
357
  if (cached && cached.signer.signUntil.getTime() > now) return cached.signer;
302
358
  throw error;
359
+ } finally {
360
+ bound.clear();
303
361
  }
304
362
  },
305
363
  (signer) => {
@@ -313,6 +371,10 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
313
371
  * The shared row lock makes emergency revocation wait for already-started
314
372
  * issuance and prevents replicas from releasing a new result under that key
315
373
  * after the revocation commits.
374
+ *
375
+ * `timeoutMs` bounds the whole call: signer refresh, identity settings, the
376
+ * wait for a connection and the key check. The caller is released at the
377
+ * deadline with a `TimeoutError` that names the step that stalled.
316
378
  */
317
379
  export const withActiveIdentitySigner = async <T>(
318
380
  purpose: SigningKeyPurpose,
@@ -322,42 +384,61 @@ export const withActiveIdentitySigner = async <T>(
322
384
  const timeoutMs = options.timeoutMs ?? 30_000;
323
385
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Identity issuance timeout must be positive");
324
386
  const deadline = Date.now() + timeoutMs;
325
- const checkDeadline = () => {
326
- options.signal?.throwIfAborted();
327
- if (Date.now() >= deadline) throw new DOMException("Identity issuance deadline exceeded", "TimeoutError");
387
+ let step = "signer refresh";
388
+ const expired = () => new DOMException(`Identity service did not respond within ${formatDuration(timeoutMs)} (${step})`, "TimeoutError");
389
+ const bound = timeoutSignal(timeoutMs, expired);
390
+ const signal = options.signal ? AbortSignal.any([options.signal, bound.signal]) : bound.signal;
391
+ // The timer releases a waiting caller. The clock also catches a timer that a
392
+ // busy event loop delivers late, and a statement Postgres cancels first.
393
+ const check = () => {
394
+ signal.throwIfAborted();
395
+ if (Date.now() >= deadline) throw expired();
328
396
  };
329
- // Preparation can refresh/rotate keys using the pool. Never do it while
330
- // holding a caller's transaction: every issuance needs just one connection.
331
- for (let attempt = 0; attempt < 2; attempt += 1) {
332
- checkDeadline();
333
- const signer = await prepareIdentitySigner(purpose);
334
- const { issuer } = await getIdentityRuntimeConfig();
335
- checkDeadline();
336
- const run = async (db: typeof sql): Promise<{ active: false } | { active: true; result: T }> => {
337
- checkDeadline();
338
- // A JS deadline alone cannot cancel a blocked PostgreSQL query. Bound
339
- // each statement (including lock waits) on this connection, transaction-local.
340
- await db`SELECT set_config('statement_timeout', ${`${Math.max(1, Math.floor(deadline - Date.now()))}ms`}, true)`;
341
- const [active] = await db<Array<{ kid: string }>>`
342
- SELECT kid
343
- FROM auth.signing_keys
344
- WHERE kid = ${signer.kid}
345
- AND purpose = ${purpose}
346
- AND state = 'active'
347
- AND sign_until > now()
348
- FOR SHARE
349
- `;
350
- checkDeadline();
351
- if (!active) return { active: false };
352
- const result = await callback({ ...signer, issuer }, db);
353
- return { active: true, result };
354
- };
355
- const checked = await (options.pool ?? sql).begin(run);
356
- checkDeadline();
357
- if (checked.active) return checked.result;
358
- invalidateIdentitySignerCache(purpose, signer.kid);
397
+ const wait = <V>(name: string, work: () => Promise<V>): Promise<V> => {
398
+ check();
399
+ step = name;
400
+ return untilAborted(work(), signal);
401
+ };
402
+ try {
403
+ // Preparation can refresh/rotate keys using the pool. Never do it while
404
+ // holding a caller's transaction: every issuance needs just one connection.
405
+ for (let attempt = 0; attempt < 2; attempt += 1) {
406
+ const signer = await wait("signer refresh", () => prepareIdentitySigner(purpose));
407
+ const { issuer } = await wait("identity settings", getIdentityRuntimeConfig);
408
+ const run = async (db: typeof sql): Promise<{ active: false } | { active: true; result: T }> => {
409
+ // A caller released at its deadline must not sign once a connection frees up.
410
+ check();
411
+ step = "signing key check";
412
+ await boundStatements(db, deadline);
413
+ const [active] = await db<Array<{ kid: string }>>`
414
+ SELECT kid
415
+ FROM auth.signing_keys
416
+ WHERE kid = ${signer.kid}
417
+ AND purpose = ${purpose}
418
+ AND state = 'active'
419
+ AND sign_until > now()
420
+ FOR SHARE
421
+ `;
422
+ check();
423
+ if (!active) return { active: false };
424
+ step = "signing";
425
+ const result = await callback({ ...signer, issuer }, db);
426
+ // Roll back what the callback wrote instead of committing a result the caller never gets.
427
+ check();
428
+ return { active: true, result };
429
+ };
430
+ const checked = await wait("database connection", () => (options.pool ?? sql).begin(run));
431
+ check();
432
+ if (checked.active) return checked.result;
433
+ invalidateIdentitySignerCache(purpose, signer.kid);
434
+ }
435
+ throw new Error(`No active ${purpose} identity signer is available`);
436
+ } catch (error) {
437
+ check();
438
+ throw error;
439
+ } finally {
440
+ bound.clear();
359
441
  }
360
- throw new Error(`No active ${purpose} identity signer is available`);
361
442
  };
362
443
 
363
444
  export const initializeIdentityAuthority = async (): Promise<void> => {
@@ -474,7 +555,7 @@ const maintainVerificationWindows = async (): Promise<void> => {
474
555
  };
475
556
 
476
557
  export const runIdentityKeyMaintenance = async (): Promise<void> => {
477
- for (const purpose of SIGNING_KEY_PURPOSES) await maintainPurpose(purpose);
558
+ for (const purpose of SIGNING_KEY_PURPOSES) await maintainPurpose(purpose, Date.now() + IDENTITY_REFRESH_TIMEOUT_MS);
478
559
  await maintainVerificationWindows();
479
560
  await sql`
480
561
  DELETE FROM auth.session_families
@@ -1,7 +1,7 @@
1
1
  import { env } from "../../config/env";
2
2
  import { publicCloudOrigin } from "../../shared/app-url";
3
3
  import { get as getSetting } from "../settings";
4
- import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_OAUTH_JWKS_PATH, CLOUD_SESSION_JWKS_PATH } from "./constants";
4
+ import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_OAUTH_JWKS_PATH, CLOUD_SESSION_JWKS_PATH, IDENTITY_REFRESH_TIMEOUT_MS } from "./constants";
5
5
 
6
6
  const RUNTIME_CONFIG_TTL_MS = 60_000;
7
7
 
@@ -73,7 +73,18 @@ export const getIdentityRuntimeConfig = async (): Promise<IdentityRuntimeConfig>
73
73
  const now = Date.now();
74
74
  if (cached && cached.expiresAt > now) return cached.value;
75
75
  if (loading) return loading;
76
- loading = load()
76
+ // Every caller joins this load. Bound it like the signer refresh, so one hung
77
+ // settings read cannot hold back every later caller; the next one starts over.
78
+ loading = new Promise<IdentityRuntimeConfig>((resolve, reject) => {
79
+ const timer = setTimeout(
80
+ () =>
81
+ reject(new DOMException(`Cloud identity settings did not load within ${IDENTITY_REFRESH_TIMEOUT_MS / 1_000} s`, "TimeoutError")),
82
+ IDENTITY_REFRESH_TIMEOUT_MS,
83
+ );
84
+ load()
85
+ .then(resolve, reject)
86
+ .finally(() => clearTimeout(timer));
87
+ })
77
88
  .then((value) => {
78
89
  cached = { value, expiresAt: Date.now() + RUNTIME_CONFIG_TTL_MS };
79
90
  return value;
@@ -12,10 +12,11 @@ import { logger } from "./logging";
12
12
  * It works in batches: claims of one outbox (table and `where`) take turns, a
13
13
  * claim takes rows in `sequence` order with several rows per ordering key, and
14
14
  * the dispatcher publishes keys concurrently and the rows of one key in order,
15
- * then completes the batch in one statement. A row that never failed is due at
16
- * once. Claims stay proportional to the batch with an index on the `where`
17
- * columns and `sequence`, and one on the `where` columns and `orderBy` limited
18
- * to `claimed_until IS NOT NULL OR attempts > 0`.
15
+ * then completes the batch in one statement. A batch starts no publish after
16
+ * its claim has ended, because the next claim takes its rows. A row that never
17
+ * failed is due at once. Claims stay proportional to the batch with an index on
18
+ * the `where` columns and `sequence`, and one on the `where` columns and
19
+ * `orderBy` limited to `claimed_until IS NOT NULL OR attempts > 0`.
19
20
  *
20
21
  * Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
21
22
  * `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
@@ -52,7 +53,9 @@ export type PgOutboxConfig<Row extends OutboxRow> = {
52
53
  };
53
54
 
54
55
  export type PgOutbox<Row extends OutboxRow> = {
56
+ /** @internal Test seam; applications use `reconcile`, `notify`, `start`, and `stop`. */
55
57
  claim(limit?: number): Promise<Row[]>;
58
+ /** @internal Test seam; it publishes one row outside the order of its key. */
56
59
  dispatch(row: Row, publish?: (row: Row) => Promise<unknown>): Promise<void>;
57
60
  /** Drain every claimable row; concurrent calls join the active pass. */
58
61
  reconcile(): Promise<number>;
@@ -78,7 +81,10 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
78
81
  const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
79
82
  const matches = (alias: "current" | "busy") =>
80
83
  filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
81
- const claimLock = `${config.table}:${JSON.stringify(config.where ?? {})}`;
84
+ // Sorted, so the order of the `where` entries cannot give one outbox two locks. Descending keeps the
85
+ // lock that live outboxes (`kind`, `app_id`) always took, so replicas of two releases share it.
86
+ const where = Object.entries(config.where ?? {}).sort(([left], [right]) => (left < right ? 1 : -1));
87
+ const claimLock = `${config.table}:${JSON.stringify(Object.fromEntries(where))}`;
82
88
 
83
89
  /**
84
90
  * Claims rows in sequence order, several per key, and never a row of a key
@@ -151,19 +157,26 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
151
157
  }
152
158
  };
153
159
 
154
- /** Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key. */
155
- const dispatchBatch = async (rows: Row[]) => {
160
+ /**
161
+ * Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key.
162
+ * A run starts no publish once the claim has ended (`claimEnds`, monotonic): another replica may own the rest by then.
163
+ */
164
+ const dispatchBatch = async (rows: Row[], claimEnds: number) => {
156
165
  const delivered: string[] = [];
157
166
  const runs = Map.groupBy(rows, (row) => row[runKey]);
158
167
  const results = await Promise.allSettled(
159
168
  [...runs.values()].map(async (run) => {
160
169
  for (const [index, row] of run.entries()) {
170
+ if (performance.now() >= claimEnds) return;
161
171
  try {
162
172
  await config.publish(row);
163
173
  } catch (error) {
164
174
  await retryLater(row, error);
165
175
  const rest = run.slice(index + 1).map((later) => later.id);
166
- if (rest.length > 0) await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
176
+ // Past the claim, the rest is free already, and releasing it could end another replica's claim.
177
+ if (rest.length > 0 && performance.now() < claimEnds) {
178
+ await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
179
+ }
167
180
  return;
168
181
  }
169
182
  delivered.push(row.id);
@@ -185,8 +198,10 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
185
198
  let rows: Row[];
186
199
  do {
187
200
  reconcileRequested = false;
201
+ // Taken before the claim, so it ends no later than the `claimed_until` the claim sets.
202
+ const claimEnds = performance.now() + claimMs;
188
203
  rows = await claim();
189
- await dispatchBatch(rows);
204
+ await dispatchBatch(rows, claimEnds);
190
205
  processed += rows.length;
191
206
  } while (reconcileRequested || rows.length > 0);
192
207
  return processed;
@@ -19,11 +19,15 @@ import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./message
19
19
  import { sendMail } from "./send";
20
20
  import { OutgoingMailError, outgoingMailStore } from "./store";
21
21
 
22
- const identity = (): Result<string, MailServiceError> => {
22
+ const identity = (read = false): Result<string, MailServiceError> => {
23
23
  const appId = getProcessApplicationId();
24
24
  if (!appId) return fail({ code: "mail_unavailable", message: "Start the application before using outgoing mail.", status: 500 });
25
- if (!getProcessPlatformPermissions().includes("mail:send"))
26
- return fail({ code: "mail_not_declared", message: 'Declare platformPermissions: ["mail:send"].', status: 403 });
25
+ if (!getProcessPlatformPermissions().includes("mail:send") && !(read && getProcessPlatformPermissions().includes("mail:read")))
26
+ return fail({
27
+ code: "mail_not_declared",
28
+ message: read ? 'Declare "mail:send" or "mail:read" in platformPermissions.' : 'Declare platformPermissions: ["mail:send"].',
29
+ status: 403,
30
+ });
27
31
  return ok(appId);
28
32
  };
29
33
  const failure = (error: unknown): MailServiceError => {
@@ -39,6 +43,16 @@ const failure = (error: unknown): MailServiceError => {
39
43
  };
40
44
  };
41
45
  export const mail = {
46
+ async readableApps(): Promise<Result<string[], MailServiceError>> {
47
+ const caller = identity(true);
48
+ if (!caller.ok) return caller;
49
+ try {
50
+ const granted = getProcessPlatformPermissions().includes("mail:read") ? await outgoingMailStore.logAppsFor(caller.data) : [];
51
+ return ok([caller.data, ...granted.filter((appId) => appId !== caller.data && appId !== "core").sort()]);
52
+ } catch (error) {
53
+ return fail(failure(error));
54
+ }
55
+ },
42
56
  async enqueue(messages: MailMessage[]): Promise<Result<{ batchId: string; ids: string[] }, MailServiceError>> {
43
57
  const caller = identity();
44
58
  const parsed = caller.ok ? MailBatchSchema.safeParse(messages) : undefined;
@@ -97,14 +111,28 @@ export const mail = {
97
111
  }
98
112
  },
99
113
  async list(filter: MailFilter = {}, page: MailPageParams = {}): Promise<Result<MailPage, MailServiceError>> {
100
- const caller = identity();
114
+ const caller = identity(true);
101
115
  if (!caller.ok) return caller;
102
116
  const parsedFilter = MailFilterSchema.safeParse(filter);
103
117
  const parsedPage = MailPageParamsSchema.safeParse(page);
104
118
  if (!parsedFilter.success || !parsedPage.success)
105
119
  return fail({ code: "bad_input", message: "Invalid outgoing mail filter or page.", status: 400 });
106
120
  try {
107
- return ok(await outgoingMailMessages.list({ ...parsedFilter.data, app: caller.data }, parsedPage.data));
121
+ const { apps = [caller.data], recipient, ...filter } = parsedFilter.data;
122
+ const otherApps = apps.filter((appId) => appId !== caller.data);
123
+ if (otherApps.length) {
124
+ if (!getProcessPlatformPermissions().includes("mail:read"))
125
+ return fail({
126
+ code: "mail_not_declared",
127
+ message: 'Declare "mail:read" in platformPermissions to read other apps’ mail logs.',
128
+ status: 403,
129
+ });
130
+ const granted = await outgoingMailStore.logAppsFor(caller.data);
131
+ const denied = otherApps.find((appId) => !granted.includes(appId));
132
+ if (denied)
133
+ return fail({ code: "app_not_allowed", message: `This application may not read the mail log of "${denied}".`, status: 403 });
134
+ }
135
+ return ok(await outgoingMailMessages.list({ ...filter, apps, recipientExact: recipient }, parsedPage.data, false, caller.data));
108
136
  } catch (error) {
109
137
  return fail(failure(error));
110
138
  }