@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.
- package/package.json +5 -3
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +111 -3
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +10 -1
- package/src/_internal/registry-validation.ts +5 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +203 -185
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +12 -26
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +4 -4
- package/src/ai/chat/turn-view.tsx +141 -69
- package/src/ai/code-runtime-tools.ts +59 -44
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +19 -21
- package/src/ai/index.ts +9 -1
- package/src/ai/migrate.ts +59 -12
- package/src/ai/protocol.ts +9 -4
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +20 -13
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/types.ts +10 -2
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +259 -100
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/widgets.ts +28 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +20 -0
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/services/accounts/users.ts +15 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/shared/network-address.ts +1 -0
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +142 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- 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. */
|
package/src/contracts/shared.ts
CHANGED
|
@@ -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
|
|
package/src/contracts/widgets.ts
CHANGED
|
@@ -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,
|
package/src/server/help.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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;
|
package/src/services/outbox.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
155
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
}
|