@k2b/cloud 0.29.1 → 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 +3 -2
- 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 +45 -9
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -2
- package/src/ai/chat/turn-view.tsx +29 -11
- 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/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/tool-sentences.ts +38 -0
- package/src/ai/types.ts +8 -1
- package/src/api/admin-ai-skills.ts +35 -16
- 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/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- 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/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/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
|
@@ -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;
|
|
@@ -2,11 +2,11 @@ import { documentNavigate } from "@k2b/ssr/nav";
|
|
|
2
2
|
import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createSignal, For, type JSX, Show } from "solid-js";
|
|
4
4
|
import { Dynamic } from "solid-js/web";
|
|
5
|
+
import { importOnDemand } from "../browser/reload";
|
|
5
6
|
import type { CloudTheme } from "../shared/theme";
|
|
6
7
|
import { AppBadge, useAppBadgeDescription } from "./AppBadge";
|
|
7
8
|
import { MobileProfileActions } from "./MobileProfileActions";
|
|
8
9
|
import { platformMessages } from "./platform-messages";
|
|
9
|
-
import { openRailEditor } from "./RailEditor";
|
|
10
10
|
import { readRailContext } from "./rail-context";
|
|
11
11
|
import { railMessages } from "./rail-messages";
|
|
12
12
|
import { projectRailNavigation } from "./rail-navigation";
|
|
@@ -64,6 +64,7 @@ export const AppLaunchpadPanel = (
|
|
|
64
64
|
const t = () => railMessages.resolve([locale()]).t;
|
|
65
65
|
const rail = readRailContext();
|
|
66
66
|
const [query, setQuery] = createSignal("");
|
|
67
|
+
const [editorLoading, setEditorLoading] = createSignal(false);
|
|
67
68
|
const messages = () => platformMessages.resolve([locale()]).t;
|
|
68
69
|
const matches = (label: string, description = "") =>
|
|
69
70
|
`${label} ${description}`.toLocaleLowerCase(locale()).includes(query().trim().toLocaleLowerCase(locale()));
|
|
@@ -106,14 +107,22 @@ export const AppLaunchpadPanel = (
|
|
|
106
107
|
label={t().customize}
|
|
107
108
|
tooltip={t().customize}
|
|
108
109
|
tooltipDelay={0}
|
|
110
|
+
aria-busy={editorLoading() ? "true" : undefined}
|
|
109
111
|
onClick={async () => {
|
|
112
|
+
if (editorLoading()) return;
|
|
113
|
+
// The editor and its validation load only when someone customizes the app bar, while this panel
|
|
114
|
+
// stays open and shows that it is busy. The button stays enabled so it keeps focus.
|
|
115
|
+
setEditorLoading(true);
|
|
116
|
+
const editor = await importOnDemand(() => import("./RailEditor"));
|
|
117
|
+
setEditorLoading(false);
|
|
118
|
+
if (!editor) return;
|
|
110
119
|
if (props.beforeSelect) {
|
|
111
120
|
if (!(await props.beforeSelect())) return;
|
|
112
121
|
} else props.close();
|
|
113
|
-
openRailEditor(locale());
|
|
122
|
+
editor.openRailEditor(locale());
|
|
114
123
|
}}
|
|
115
124
|
>
|
|
116
|
-
<i class="ti ti-adjustments-horizontal" aria-hidden="true" />
|
|
125
|
+
<i class={editorLoading() ? "ti ti-loader-2 animate-spin" : "ti ti-adjustments-horizontal"} aria-hidden="true" />
|
|
117
126
|
</IconButton>
|
|
118
127
|
</div>
|
|
119
128
|
</Show>
|
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
ANNOUNCEMENTS_COOKIE,
|
|
5
5
|
ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
|
|
6
6
|
type AnnouncementCookieState,
|
|
7
|
-
type AnnouncementDisplayEntry,
|
|
8
7
|
mergeAnnouncementCookieState,
|
|
9
8
|
serializeAnnouncementCookieState,
|
|
10
|
-
} from "../contracts/
|
|
9
|
+
} from "../contracts/announcement-cookie";
|
|
10
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
11
11
|
import { platformMessages } from "./platform-messages";
|
|
12
12
|
|
|
13
13
|
type Props = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { query } from "@k2b/stdlib/solid";
|
|
2
|
-
import { type DialogRender,
|
|
2
|
+
import { type DialogRender, toast, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js";
|
|
4
4
|
import type { SearchItem } from "../api/search/schemas";
|
|
5
5
|
import CloudResourceSearch from "../browser/CloudResourceSearch";
|
|
@@ -7,7 +7,6 @@ import { COMMANDS_CHANGED, collectContextAwareCommands } from "../browser/comman
|
|
|
7
7
|
import { contextCommandsWithShortcuts } from "../browser/command-shortcuts";
|
|
8
8
|
import { openCommand, runContextAwareCommand } from "../browser/commands";
|
|
9
9
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
10
|
-
import { resourceSearchDialogOptions } from "../browser/resource-search-dialog";
|
|
11
10
|
import { resourceSearchMessages } from "../browser/resource-search-messages";
|
|
12
11
|
import { type GlobalSearchOptions, requestSearchNavigation } from "../browser/search-bridge";
|
|
13
12
|
import { loadSearchCommands, type PaletteCommand } from "../browser/search-commands";
|
|
@@ -139,44 +138,3 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
139
138
|
</div>
|
|
140
139
|
);
|
|
141
140
|
}
|
|
142
|
-
|
|
143
|
-
/** Owned by the layout island; neither dialog state nor callbacks cross SSR props. */
|
|
144
|
-
export const createGlobalSearchHost = (searchLinks: () => NavigationSearchItem[], searchResources = true) => {
|
|
145
|
-
const [request, setRequest] = createSignal<GlobalSearchOptions>({});
|
|
146
|
-
let controller: AbortController | undefined;
|
|
147
|
-
return {
|
|
148
|
-
open: (options: GlobalSearchOptions) => {
|
|
149
|
-
setRequest({ ...options });
|
|
150
|
-
if (controller) {
|
|
151
|
-
document.querySelector<HTMLInputElement>(".cloud-global-search input[role=combobox]")?.focus();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const current = new AbortController();
|
|
155
|
-
controller = current;
|
|
156
|
-
const locale = document.documentElement.lang || "en";
|
|
157
|
-
void dialogCore
|
|
158
|
-
.open<void>(
|
|
159
|
-
(close, context) => (
|
|
160
|
-
<GlobalSearchDialog
|
|
161
|
-
request={request()}
|
|
162
|
-
searchLinks={searchLinks()}
|
|
163
|
-
searchResources={searchResources}
|
|
164
|
-
close={close}
|
|
165
|
-
context={context}
|
|
166
|
-
/>
|
|
167
|
-
),
|
|
168
|
-
{ ...resourceSearchDialogOptions(resourceSearchMessages.resolve([locale]).t.searchCloudResources), signal: current.signal },
|
|
169
|
-
)
|
|
170
|
-
.catch(() => {
|
|
171
|
-
toast.error(resourceSearchMessages.resolve([locale]).t.searchFailed);
|
|
172
|
-
})
|
|
173
|
-
.finally(() => {
|
|
174
|
-
if (controller === current) controller = undefined;
|
|
175
|
-
});
|
|
176
|
-
},
|
|
177
|
-
dispose: () => {
|
|
178
|
-
controller?.abort();
|
|
179
|
-
controller = undefined;
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
3
4
|
import { attachCommandShortcuts } from "../browser/command-shortcuts";
|
|
4
|
-
import { registerContextAwareCommand, runContextAwareCommand } from "../browser/commands";
|
|
5
5
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
6
|
+
import { importOnDemand } from "../browser/reload";
|
|
6
7
|
import { openGlobalSearch } from "../browser/search";
|
|
7
8
|
import { registerGlobalSearchHost } from "../browser/search-bridge";
|
|
8
|
-
import { createGlobalSearchHost } from "./
|
|
9
|
+
import { createGlobalSearchHost } from "./global-search-host";
|
|
9
10
|
import { platformMessages } from "./platform-messages";
|
|
10
11
|
|
|
11
12
|
type GlobalSearchTriggerProps = {
|
|
@@ -26,7 +27,15 @@ export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
|
|
|
26
27
|
onMount(() => {
|
|
27
28
|
onCleanup(registerGlobalSearchHost(host.open, host.dispose));
|
|
28
29
|
});
|
|
29
|
-
|
|
30
|
+
// Commands, their schemas and the capability client load on the first shortcut, not with every page.
|
|
31
|
+
onMount(() =>
|
|
32
|
+
onCleanup(
|
|
33
|
+
attachCommandShortcuts(
|
|
34
|
+
(command) =>
|
|
35
|
+
void importOnDemand(() => import("../browser/commands")).then((commands) => commands?.runContextAwareCommand(command)),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
30
39
|
createEffect(() =>
|
|
31
40
|
onCleanup(
|
|
32
41
|
registerContextAwareCommand({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createEffect, onCleanup } from "solid-js";
|
|
3
|
-
import { registerContextAwareCommand } from "../browser/
|
|
3
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
4
4
|
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
5
5
|
import { openLayoutHelpDialog } from "./LayoutHelp";
|
|
6
6
|
import { platformMessages } from "./platform-messages";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
|
|
2
2
|
import { onCleanup, onMount } from "solid-js";
|
|
3
3
|
import { renewAppSession } from "../browser/app-session";
|
|
4
|
-
import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa";
|
|
4
|
+
import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa-paths";
|
|
5
5
|
import { pwaMessages } from "./pwa-messages";
|
|
6
6
|
|
|
7
7
|
/** Set once per browsing session, so the first page renews and later navigations do not. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { hasAnyAppRole } from "../_internal/app-roles";
|
|
1
2
|
import type { RuntimeAppMeta } from "../contracts/app";
|
|
2
|
-
import { isPwaPartId } from "../contracts/pwa";
|
|
3
|
-
import
|
|
3
|
+
import { isPwaPartId } from "../contracts/pwa-paths";
|
|
4
|
+
import type { User } from "../contracts/shared";
|
|
4
5
|
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
5
6
|
|
|
6
7
|
export type VisibleNavigationApp = RuntimeAppMeta & {
|
|
@@ -27,14 +28,11 @@ export const hasDedicatedRuntimeRoute = (apps: readonly RuntimeAppMeta[], pathna
|
|
|
27
28
|
return !!match && match.app.id !== currentAppId;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
const hasAnyRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
|
|
31
|
-
!roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : hasRole(user, role))));
|
|
32
|
-
|
|
33
31
|
/** Apps rendered in the navigation for a given authenticated user. */
|
|
34
32
|
export const visibleNavigationApps = (apps: readonly RuntimeAppMeta[], user: User | undefined): VisibleNavigationApp[] =>
|
|
35
33
|
apps.filter(
|
|
36
34
|
(app): app is VisibleNavigationApp =>
|
|
37
|
-
!!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) &&
|
|
35
|
+
!!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyAppRole(user, app.nav.requiresRoles),
|
|
38
36
|
);
|
|
39
37
|
|
|
40
38
|
export type VisiblePwaPart = RuntimeAppMeta & { pwa: NonNullable<RuntimeAppMeta["pwa"]> };
|
|
@@ -49,7 +47,7 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
|
|
|
49
47
|
return apps
|
|
50
48
|
.filter(
|
|
51
49
|
(app): app is VisiblePwaPart =>
|
|
52
|
-
!!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) &&
|
|
50
|
+
!!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyAppRole(user, app.pwa.requiresRoles),
|
|
53
51
|
)
|
|
54
52
|
.map((app) => resolveAppPresentation(app, locale))
|
|
55
53
|
.sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { dialogCore, toast } from "@k2b/ui";
|
|
2
|
+
import { createSignal } from "solid-js";
|
|
3
|
+
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
4
|
+
import { importOnDemand } from "../browser/reload";
|
|
5
|
+
import { resourceSearchDialogOptions } from "../browser/resource-search-dialog";
|
|
6
|
+
import { resourceSearchMessages } from "../browser/resource-search-messages";
|
|
7
|
+
import type { GlobalSearchOptions } from "../browser/search-bridge";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owned by the layout island; neither dialog state nor callbacks cross SSR props. The dialog with search, commands
|
|
11
|
+
* and their schemas loads on first open, so no page pays for it before someone searches.
|
|
12
|
+
*/
|
|
13
|
+
export const createGlobalSearchHost = (searchLinks: () => NavigationSearchItem[], searchResources = true) => {
|
|
14
|
+
const [request, setRequest] = createSignal<GlobalSearchOptions>({});
|
|
15
|
+
let controller: AbortController | undefined;
|
|
16
|
+
return {
|
|
17
|
+
open: (options: GlobalSearchOptions) => {
|
|
18
|
+
setRequest({ ...options });
|
|
19
|
+
if (controller) {
|
|
20
|
+
document.querySelector<HTMLInputElement>(".cloud-global-search input[role=combobox]")?.focus();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const current = new AbortController();
|
|
24
|
+
controller = current;
|
|
25
|
+
const locale = document.documentElement.lang || "en";
|
|
26
|
+
void importOnDemand(() => import("./GlobalSearchDialog"))
|
|
27
|
+
.then((loaded) => {
|
|
28
|
+
if (!loaded || current.signal.aborted) return;
|
|
29
|
+
const GlobalSearchDialog = loaded.default;
|
|
30
|
+
return dialogCore.open<void>(
|
|
31
|
+
(close, context) => (
|
|
32
|
+
<GlobalSearchDialog
|
|
33
|
+
request={request()}
|
|
34
|
+
searchLinks={searchLinks()}
|
|
35
|
+
searchResources={searchResources}
|
|
36
|
+
close={close}
|
|
37
|
+
context={context}
|
|
38
|
+
/>
|
|
39
|
+
),
|
|
40
|
+
{ ...resourceSearchDialogOptions(resourceSearchMessages.resolve([locale]).t.searchCloudResources), signal: current.signal },
|
|
41
|
+
);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
toast.error(resourceSearchMessages.resolve([locale]).t.searchFailed);
|
|
45
|
+
})
|
|
46
|
+
.finally(() => {
|
|
47
|
+
if (controller === current) controller = undefined;
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
dispose: () => {
|
|
51
|
+
controller?.abort();
|
|
52
|
+
controller = undefined;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
};
|
package/src/ssr/help.ts
CHANGED
|
@@ -10,14 +10,14 @@ const manifests = new WeakMap<object, HelpManifestResult | null>();
|
|
|
10
10
|
const log = logger("help:ssr");
|
|
11
11
|
export const getLayoutHelp = (c: object): HelpManifestResult | null => manifests.get(c) ?? null;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** Help metadata for the current or an explicit app; `null` without a user or when the user may not see the app. */
|
|
14
14
|
export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<HelpManifestResult | null> => {
|
|
15
15
|
if (manifests.has(c)) return getLayoutHelp(c);
|
|
16
|
-
if (!c.get("runtime") ||
|
|
16
|
+
if (!c.get("runtime") || !c.get("user")) return null;
|
|
17
17
|
const id = appId ?? resolveCurrentApp(getRuntimeContext(c).apps, c.req.path)?.id;
|
|
18
18
|
if (!id) return null;
|
|
19
19
|
try {
|
|
20
|
-
const manifest = await createHelpReader(getLocale(c)).manifest(id);
|
|
20
|
+
const manifest = await createHelpReader(getLocale(c), c.get("actor")).manifest(id);
|
|
21
21
|
manifests.set(c, manifest);
|
|
22
22
|
return manifest;
|
|
23
23
|
} catch (error) {
|