@k2b/cloud 0.44.0 → 0.46.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 +9 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +22 -4
- package/src/ai/index.ts +1 -0
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/protocol.ts +3 -1
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +32 -30
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/shared.ts +2 -1
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +56 -17
- package/src/services/accounts/entities.ts +36 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +26 -17
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +141 -27
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/accounts/users.ts +38 -8
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +428 -77
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/styles/global.css +2 -0
- package/src/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +2 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
|
|
3
|
+
import { err, fail, ok, type Result } from "../../server/services";
|
|
4
|
+
import { audit } from "../audit";
|
|
5
|
+
import * as localGroups from "./local-groups";
|
|
6
|
+
|
|
7
|
+
/** Runs the existing local membership operation while its direct management grant is locked. */
|
|
8
|
+
export async function mutateMachineGroupMember(params: {
|
|
9
|
+
actor: ServiceAccountRequestActor;
|
|
10
|
+
id: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
groupId?: string;
|
|
13
|
+
serviceAccountId?: string;
|
|
14
|
+
operation: "add" | "remove";
|
|
15
|
+
requestId?: string;
|
|
16
|
+
}): Promise<Result<void>> {
|
|
17
|
+
const { actor } = params;
|
|
18
|
+
const action = `accounts.group.member.${params.operation}`;
|
|
19
|
+
const target = { type: "group", id: params.id };
|
|
20
|
+
const metadata = {
|
|
21
|
+
actorServiceAccountId: actor.serviceAccount.id,
|
|
22
|
+
credentialId: actor.credentialId ?? null,
|
|
23
|
+
oauthClientId: actor.oauthClientId ?? null,
|
|
24
|
+
userId: params.userId ?? null,
|
|
25
|
+
groupId: params.groupId ?? null,
|
|
26
|
+
serviceAccountId: params.serviceAccountId ?? null,
|
|
27
|
+
};
|
|
28
|
+
const unexpired = (expiration: string | Date | null | undefined) =>
|
|
29
|
+
expiration == null || (typeof expiration === "string" ? Date.parse(expiration) : expiration.getTime()) > Date.now();
|
|
30
|
+
const denied = () => fail(err.forbidden("A current management grant for this group and write scope are required"));
|
|
31
|
+
if (
|
|
32
|
+
actor.delegatedUser ||
|
|
33
|
+
!["standalone", "agent"].includes(actor.serviceAccount.kind) ||
|
|
34
|
+
!actor.scopes.some((scope) => scope === "write" || scope === "admin") ||
|
|
35
|
+
!unexpired(actor.credentialExpiresAt) ||
|
|
36
|
+
(!actor.credentialId && (!actor.oauthClientId || !actor.credentialExpiresAt))
|
|
37
|
+
)
|
|
38
|
+
return audit.recordResult({ action, target, metadata, requestId: params.requestId, result: denied() });
|
|
39
|
+
|
|
40
|
+
return sql.begin(async (db) => {
|
|
41
|
+
// Account -> credential -> group -> grant matches the group deletion cascade.
|
|
42
|
+
const [account] = await db`SELECT id FROM auth.service_accounts
|
|
43
|
+
WHERE id = ${actor.serviceAccount.id}::uuid AND status = 'active' AND kind IN ('standalone','agent') FOR SHARE`;
|
|
44
|
+
let allowed = Boolean(account);
|
|
45
|
+
let storedExpiration: Date | null = null;
|
|
46
|
+
if (allowed && actor.credentialId) {
|
|
47
|
+
const [credential] = await db<{ expires_at: Date | null }[]>`SELECT expires_at FROM auth.service_account_credentials
|
|
48
|
+
WHERE id = ${actor.credentialId}::uuid AND service_account_id = ${actor.serviceAccount.id}::uuid
|
|
49
|
+
AND status = 'active' AND (expires_at IS NULL OR expires_at > clock_timestamp())
|
|
50
|
+
AND scopes && ARRAY['write','admin']::text[] FOR SHARE`;
|
|
51
|
+
allowed = Boolean(credential);
|
|
52
|
+
storedExpiration = credential?.expires_at ?? null;
|
|
53
|
+
} else if (allowed) {
|
|
54
|
+
const [client] = await db`SELECT id FROM oauth.clients WHERE client_id=${actor.oauthClientId ?? null} FOR SHARE`;
|
|
55
|
+
allowed = Boolean(client);
|
|
56
|
+
}
|
|
57
|
+
const [group] = allowed ? await db`SELECT id FROM auth.groups WHERE id=${params.id}::uuid AND provider='local' FOR SHARE` : [];
|
|
58
|
+
const [grant] = group
|
|
59
|
+
? await db`SELECT group_id FROM auth.group_manager_service_accounts
|
|
60
|
+
WHERE group_id=${params.id}::uuid AND service_account_id=${actor.serviceAccount.id}::uuid FOR SHARE`
|
|
61
|
+
: [];
|
|
62
|
+
let result: Result<void> = denied();
|
|
63
|
+
// The authorization locks define admission; time must be current after any lock wait.
|
|
64
|
+
if (grant && unexpired(actor.credentialExpiresAt) && unexpired(storedExpiration)) {
|
|
65
|
+
if ([params.userId, params.groupId, params.serviceAccountId].filter(Boolean).length !== 1) {
|
|
66
|
+
result = fail(err.badInput("Choose exactly one member"));
|
|
67
|
+
} else {
|
|
68
|
+
const changed = await localGroups[params.operation === "add" ? "addMember" : "removeMember"](
|
|
69
|
+
{
|
|
70
|
+
id: params.id,
|
|
71
|
+
user: params.userId,
|
|
72
|
+
group: params.groupId,
|
|
73
|
+
serviceAccount: params.serviceAccountId,
|
|
74
|
+
},
|
|
75
|
+
db,
|
|
76
|
+
);
|
|
77
|
+
result = changed.ok
|
|
78
|
+
? ok()
|
|
79
|
+
: fail({ code: changed.status === 403 ? "FORBIDDEN" : "GROUP_MUTATION_FAILED", message: changed.error, status: changed.status });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await audit.record(
|
|
83
|
+
{
|
|
84
|
+
action,
|
|
85
|
+
target,
|
|
86
|
+
metadata,
|
|
87
|
+
requestId: params.requestId,
|
|
88
|
+
outcome: result.ok ? "allowed" : result.error.status === 403 ? "denied" : "failed",
|
|
89
|
+
},
|
|
90
|
+
db,
|
|
91
|
+
);
|
|
92
|
+
return result;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BaseUser,
|
|
4
|
+
MutationResult,
|
|
5
|
+
PaginationResponse,
|
|
6
|
+
RequestActor,
|
|
7
|
+
Role,
|
|
8
|
+
User,
|
|
9
|
+
UserProfile,
|
|
10
|
+
UserProvider,
|
|
11
|
+
} from "../../contracts/shared";
|
|
3
12
|
import { freeipa } from "../../server/services";
|
|
4
13
|
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
5
14
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
@@ -225,9 +234,10 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
|
|
|
225
234
|
};
|
|
226
235
|
|
|
227
236
|
/** Audience roles for a resolved access subject, including delegated users and group managers. */
|
|
228
|
-
export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
229
|
-
const
|
|
230
|
-
const
|
|
237
|
+
export const getRoles = async (userId: string, options?: { db: typeof sql }): Promise<Role[]> => {
|
|
238
|
+
const db = options?.db ?? sql;
|
|
239
|
+
const groupsAdmin = options ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
|
|
240
|
+
const [row] = await db<DbRow[]>`SELECT u.provider, u.profile,
|
|
231
241
|
CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
|
|
232
242
|
SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
|
|
233
243
|
AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
@@ -239,6 +249,26 @@ export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
|
239
249
|
return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
|
|
240
250
|
};
|
|
241
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Revalidate installation authority and retain it until the caller commits.
|
|
254
|
+
* Lock users before grant/membership or mandate/profile locks, matching user
|
|
255
|
+
* deletion and provider sync. Projection and settings locks also cover rows
|
|
256
|
+
* inserted or replaced while lock acquisition was waiting.
|
|
257
|
+
*/
|
|
258
|
+
export const hasInstallationAdminInTransaction = async (actor: RequestActor, db: typeof sql): Promise<boolean> => {
|
|
259
|
+
if (actor.kind !== "user" || actor.sessionKind === "app" || !actor.user.roles.includes("admin")) return false;
|
|
260
|
+
const [user] = await db<{ provider: UserProvider; profile: UserProfile; expired: boolean }[]>`
|
|
261
|
+
SELECT provider, profile, (account_expires IS NOT NULL AND account_expires <= statement_timestamp()) AS expired
|
|
262
|
+
FROM auth.users WHERE id=${actor.user.id}::uuid FOR SHARE`;
|
|
263
|
+
if (!user || user.expired || user.profile !== "user") return false;
|
|
264
|
+
await db`LOCK TABLE settings.entries IN SHARE MODE`;
|
|
265
|
+
if (!(await isAccountCategoryAllowed(user, db))) return false;
|
|
266
|
+
if (user.provider === "ipa") {
|
|
267
|
+
await db`LOCK TABLE auth.ipa_user_effective_groups IN SHARE MODE`;
|
|
268
|
+
}
|
|
269
|
+
return (await getRoles(actor.user.id, { db })).includes("admin");
|
|
270
|
+
};
|
|
271
|
+
|
|
242
272
|
export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
|
|
243
273
|
const { groupsAdmin } = await getFreeIpaConfig();
|
|
244
274
|
const rows = await sql<DbRow[]>`
|
|
@@ -347,15 +377,15 @@ export const list = async (params: {
|
|
|
347
377
|
};
|
|
348
378
|
};
|
|
349
379
|
|
|
350
|
-
/**
|
|
351
|
-
export const getGroups = async (params: { id: string; recursive?: boolean
|
|
380
|
+
/** Exported group names exclude personal groups; authorization uses the complete group ID projection. */
|
|
381
|
+
export const getGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
|
|
352
382
|
if (params.recursive) {
|
|
353
383
|
const rows = await sql<DbRow[]>`${recursiveUserGroupsSubquery({
|
|
354
384
|
userId: params.id,
|
|
355
385
|
select: sql`
|
|
356
386
|
SELECT DISTINCT g.name FROM user_all_groups ag
|
|
357
387
|
JOIN auth.groups g ON g.id = ag.group_id
|
|
358
|
-
WHERE
|
|
388
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
359
389
|
ORDER BY g.name
|
|
360
390
|
`,
|
|
361
391
|
})}`;
|
|
@@ -364,7 +394,7 @@ export const getGroups = async (params: { id: string; recursive?: boolean; inclu
|
|
|
364
394
|
const rows = await sql<DbRow[]>`
|
|
365
395
|
SELECT g.name FROM auth.groups g
|
|
366
396
|
WHERE g.id IN (SELECT group_id FROM auth.user_groups_v2 WHERE user_id = ${params.id}::uuid)
|
|
367
|
-
AND
|
|
397
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
368
398
|
ORDER BY g.name
|
|
369
399
|
`;
|
|
370
400
|
return rows.map((row) => row.name as string);
|
|
@@ -4,13 +4,14 @@ import type { GeoFunction } from "../../contracts/geo";
|
|
|
4
4
|
import { requestCacheRedis } from "../request-cache-redis";
|
|
5
5
|
import type { GeoRedis } from "./bucket";
|
|
6
6
|
|
|
7
|
-
export const GEO_ADAPTER_VERSION = "geoapify-
|
|
7
|
+
export const GEO_ADAPTER_VERSION = "geoapify-v3";
|
|
8
8
|
export const GEO_MAX_JSON_BYTES = 256 * 1024;
|
|
9
9
|
// Raw provider geometry is compacted before entering the public result/cache budget.
|
|
10
10
|
export const GEO_MAX_ROUTE_RESPONSE_BYTES = 26 * GEO_MAX_JSON_BYTES;
|
|
11
11
|
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
12
12
|
export const normalizeGeoInput = (input: unknown, fn: GeoFunction, path = ""): unknown => {
|
|
13
13
|
const field = path.split(".").at(-1);
|
|
14
|
+
if (typeof input === "string" && fn === "places" && field === "name") return input.trim();
|
|
14
15
|
if (typeof input === "string" && fn === "route") return field === "polyline6" ? hash(input) : input;
|
|
15
16
|
if (typeof input === "string") return field === "polyline6" ? hash(input) : input.toLowerCase().trim().replace(/\s+/g, " ");
|
|
16
17
|
if (typeof input === "number") {
|
|
@@ -86,6 +86,7 @@ const address = (p: RawProperties, provenance: GeoProvenance): GeoAddress => ({
|
|
|
86
86
|
point: { lat: p.lat, lon: p.lon },
|
|
87
87
|
formatted: p.formatted ?? p.address_line1 ?? "",
|
|
88
88
|
confidence: p.rank?.confidence ?? null,
|
|
89
|
+
buildingConfidence: p.rank?.confidence_building_level ?? null,
|
|
89
90
|
provenance,
|
|
90
91
|
});
|
|
91
92
|
const areaFilter = (area: NonNullable<GeoAutocompleteInput["area"]>) =>
|
|
@@ -247,6 +248,7 @@ export class GeoapifyAdapter {
|
|
|
247
248
|
if (input.along && line)
|
|
248
249
|
body = {
|
|
249
250
|
categories,
|
|
251
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
250
252
|
limit: input.limit ?? 20,
|
|
251
253
|
filter: {
|
|
252
254
|
type: "polyline",
|
|
@@ -256,6 +258,7 @@ export class GeoapifyAdapter {
|
|
|
256
258
|
};
|
|
257
259
|
else {
|
|
258
260
|
params.set("categories", categories.join(","));
|
|
261
|
+
if (input.name !== undefined) params.set("name", input.name);
|
|
259
262
|
if (input.near) params.set("bias", `proximity:${input.near.lon},${input.near.lat}`);
|
|
260
263
|
if (input.area) params.set("filter", areaFilter(input.area));
|
|
261
264
|
}
|
|
@@ -485,3 +485,113 @@ export const verifyAppAccessProbe = async (
|
|
|
485
485
|
return null;
|
|
486
486
|
}
|
|
487
487
|
};
|
|
488
|
+
|
|
489
|
+
// A separate payload prevents app authority from ever entering the person/mandate resolver.
|
|
490
|
+
const AppQueryClaimsSchema = z
|
|
491
|
+
.object({
|
|
492
|
+
iss: z.string().url(),
|
|
493
|
+
aud: z.string().regex(/^app:[a-z][a-z0-9-]{0,79}$/),
|
|
494
|
+
token_use: z.literal("app_query"),
|
|
495
|
+
sub: AppIdSchema,
|
|
496
|
+
credential_id: z.uuid(),
|
|
497
|
+
service_account_id: z.uuid(),
|
|
498
|
+
op: OperationSchema.refine((value) => value.startsWith("capability.query:")),
|
|
499
|
+
schema_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
500
|
+
input_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
501
|
+
request_id: BoundedIdentifierSchema,
|
|
502
|
+
jti: z.uuid(),
|
|
503
|
+
iat: z.number().int(),
|
|
504
|
+
nbf: z.number().int(),
|
|
505
|
+
exp: z.number().int(),
|
|
506
|
+
})
|
|
507
|
+
.strict()
|
|
508
|
+
.refine((claims) => claims.nbf === claims.iat && claims.exp - claims.iat === CLOUD_INVOCATION_TOKEN_TTL_SECONDS);
|
|
509
|
+
|
|
510
|
+
export const signAppQueryInvocation = async (input: {
|
|
511
|
+
workload: import("./workload-auth").AuthenticatedWorkload;
|
|
512
|
+
targetAppId: string;
|
|
513
|
+
capabilityId: string;
|
|
514
|
+
schemaHash: string;
|
|
515
|
+
input: unknown;
|
|
516
|
+
requestId: string;
|
|
517
|
+
signer: PreparedIdentitySigner & { issuer: string };
|
|
518
|
+
}) => {
|
|
519
|
+
const now = Math.floor(Date.now() / 1000);
|
|
520
|
+
if (input.signer.signUntil.getTime() < (now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS) * 1000)
|
|
521
|
+
throw new Error("Signing key expires before the app query");
|
|
522
|
+
const claims = AppQueryClaimsSchema.parse({
|
|
523
|
+
iss: input.signer.issuer,
|
|
524
|
+
aud: `app:${input.targetAppId}`,
|
|
525
|
+
token_use: "app_query",
|
|
526
|
+
sub: input.workload.appId,
|
|
527
|
+
credential_id: input.workload.credentialId,
|
|
528
|
+
service_account_id: input.workload.serviceAccountId,
|
|
529
|
+
op: `capability.query:${input.capabilityId}`,
|
|
530
|
+
schema_hash: input.schemaHash,
|
|
531
|
+
input_hash: appEventPayloadHash(input.input),
|
|
532
|
+
request_id: input.requestId,
|
|
533
|
+
jti: crypto.randomUUID(),
|
|
534
|
+
iat: now,
|
|
535
|
+
nbf: now,
|
|
536
|
+
exp: now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS,
|
|
537
|
+
});
|
|
538
|
+
const token = await new SignJWT(claims)
|
|
539
|
+
.setProtectedHeader({ alg: CLOUD_IDENTITY_ALGORITHM, typ: CLOUD_INVOCATION_TOKEN_TYPE, kid: input.signer.kid })
|
|
540
|
+
.sign(input.signer.key);
|
|
541
|
+
if (Buffer.byteLength(token) > IDENTITY_MAX_COMPACT_TOKEN_BYTES) throw new Error("App query token exceeds its byte budget");
|
|
542
|
+
return { token, claims };
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
export const verifyAppQueryInvocation = async (
|
|
546
|
+
token: string,
|
|
547
|
+
expected: { appId: string; capabilityId: string; schemaHash: string; input: unknown },
|
|
548
|
+
options: { issuer?: string; key?: JWTVerifyGetKey; now?: Date } = {},
|
|
549
|
+
) => {
|
|
550
|
+
if (Buffer.byteLength(token) > IDENTITY_MAX_COMPACT_TOKEN_BYTES) return null;
|
|
551
|
+
try {
|
|
552
|
+
assertProtectedHeader(decodeProtectedHeader(token));
|
|
553
|
+
} catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
// Configuration and key-fetch failures must remain retryable, rather than look like invalid tokens.
|
|
557
|
+
const runtime = options.issuer && options.key ? null : await getIdentityRuntimeConfig();
|
|
558
|
+
try {
|
|
559
|
+
const issuer = options.issuer ?? runtime!.issuer;
|
|
560
|
+
const state = options.key ? null : remoteSet(runtime!.invocationJwksUrl);
|
|
561
|
+
const key = options.key ?? state!.key;
|
|
562
|
+
const verified = await verifyWithKeyRefresh(
|
|
563
|
+
(candidate) =>
|
|
564
|
+
jwtVerify(token, candidate, {
|
|
565
|
+
algorithms: [CLOUD_IDENTITY_ALGORITHM],
|
|
566
|
+
issuer,
|
|
567
|
+
audience: `app:${expected.appId}`,
|
|
568
|
+
typ: CLOUD_INVOCATION_TOKEN_TYPE,
|
|
569
|
+
clockTolerance: INVOCATION_CLOCK_TOLERANCE_SECONDS,
|
|
570
|
+
currentDate: options.now,
|
|
571
|
+
}),
|
|
572
|
+
key,
|
|
573
|
+
state ? { url: runtime!.invocationJwksUrl, state } : null,
|
|
574
|
+
);
|
|
575
|
+
const claims = AppQueryClaimsSchema.parse(verified.payload);
|
|
576
|
+
if (
|
|
577
|
+
claims.op !== `capability.query:${expected.capabilityId}` ||
|
|
578
|
+
claims.schema_hash !== expected.schemaHash ||
|
|
579
|
+
claims.input_hash !== appEventPayloadHash(expected.input)
|
|
580
|
+
)
|
|
581
|
+
return null;
|
|
582
|
+
return claims;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (
|
|
585
|
+
error instanceof z.ZodError ||
|
|
586
|
+
error instanceof errors.JWTExpired ||
|
|
587
|
+
error instanceof errors.JWTClaimValidationFailed ||
|
|
588
|
+
error instanceof errors.JWTInvalid ||
|
|
589
|
+
error instanceof errors.JWSInvalid ||
|
|
590
|
+
error instanceof errors.JWSSignatureVerificationFailed ||
|
|
591
|
+
error instanceof errors.JWKSNoMatchingKey ||
|
|
592
|
+
error instanceof errors.JOSENotSupported
|
|
593
|
+
)
|
|
594
|
+
return null;
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
@@ -207,7 +207,7 @@ const validSubject = async (subject: MandateSubject, db: SQL): Promise<boolean>
|
|
|
207
207
|
SELECT EXISTS (
|
|
208
208
|
SELECT 1 FROM auth.service_accounts
|
|
209
209
|
WHERE id = ${subject.id}::uuid
|
|
210
|
-
AND kind
|
|
210
|
+
AND kind IN ('resource_bound', 'standalone', 'agent')
|
|
211
211
|
AND status = 'active'
|
|
212
212
|
) AS active
|
|
213
213
|
`;
|
|
@@ -687,7 +687,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
687
687
|
WHEN 'user' THEN user_account.id IS NOT NULL
|
|
688
688
|
AND (user_account.account_expires IS NULL OR user_account.account_expires > now())
|
|
689
689
|
WHEN 'service_account' THEN service_account.id IS NOT NULL
|
|
690
|
-
AND service_account.kind
|
|
690
|
+
AND service_account.kind IN ('resource_bound', 'standalone', 'agent')
|
|
691
691
|
AND service_account.status = 'active'
|
|
692
692
|
ELSE false
|
|
693
693
|
END AS subject_active
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GEO_SNAPSHOT_MAX_BYTES,
|
|
3
|
+
GEO_SNAPSHOT_MAX_METADATA_BYTES,
|
|
4
|
+
type GeoMapSnapshotManifest,
|
|
5
|
+
GeoMapSnapshotManifestSchema,
|
|
6
|
+
geoMapSnapshotPart,
|
|
7
|
+
} from "../../contracts/geo-snapshot";
|
|
8
|
+
import { type PreparePdfImageOptions, preparePdfImage } from "./image";
|
|
9
|
+
|
|
10
|
+
export type GeoMapSnapshotAsset = { id: string; name: string; data: Blob; type: "image/png" | "image/jpeg"; width: number; height: number };
|
|
11
|
+
export type GeoMapSnapshotAssetErrorCode = "invalid" | "stale" | "missing" | "too_large" | "unreadable" | "timed_out" | "aborted";
|
|
12
|
+
export class GeoMapSnapshotAssetError extends Error {
|
|
13
|
+
constructor(readonly code: GeoMapSnapshotAssetErrorCode) {
|
|
14
|
+
super(`Map snapshot upload: ${code}`);
|
|
15
|
+
this.name = "GeoMapSnapshotAssetError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Application calls only after authorizing and reading its canonical saved revision/maps.
|
|
21
|
+
* Validates a presentation upload; it cannot attest that the client's pixels depict the saved geography.
|
|
22
|
+
* Enforce a streaming HTTP body limit BEFORE parsing FormData. Never accept resource URLs or renderer credentials.
|
|
23
|
+
*/
|
|
24
|
+
export async function prepareGeoMapSnapshotAssets(
|
|
25
|
+
form: FormData,
|
|
26
|
+
expected: { revision: string; maps: Pick<GeoMapSnapshotManifest["images"][number], "id" | "fingerprint">[]; maxBytes: number },
|
|
27
|
+
options: PreparePdfImageOptions = {},
|
|
28
|
+
): Promise<GeoMapSnapshotAsset[]> {
|
|
29
|
+
const fail = (code: GeoMapSnapshotAssetErrorCode): never => {
|
|
30
|
+
throw new GeoMapSnapshotAssetError(code);
|
|
31
|
+
};
|
|
32
|
+
if (!Number.isSafeInteger(expected.maxBytes) || expected.maxBytes < 0) throw new RangeError("Invalid PDF asset budget");
|
|
33
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
34
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 240_000) throw new RangeError("Invalid map preflight timeout");
|
|
35
|
+
const deadline = Date.now() + timeout;
|
|
36
|
+
const check = () => {
|
|
37
|
+
if (options.signal?.aborted) fail("aborted");
|
|
38
|
+
if (Date.now() >= deadline) fail("timed_out");
|
|
39
|
+
};
|
|
40
|
+
check();
|
|
41
|
+
const metadata = form.getAll("mapSnapshots");
|
|
42
|
+
if (metadata.length !== 1 || typeof metadata[0] !== "string") return fail("invalid");
|
|
43
|
+
if (new TextEncoder().encode(metadata[0]).byteLength > GEO_SNAPSHOT_MAX_METADATA_BYTES) return fail("too_large");
|
|
44
|
+
let manifest: GeoMapSnapshotManifest;
|
|
45
|
+
try {
|
|
46
|
+
manifest = GeoMapSnapshotManifestSchema.parse(JSON.parse(metadata[0]));
|
|
47
|
+
} catch {
|
|
48
|
+
return fail("invalid");
|
|
49
|
+
}
|
|
50
|
+
if (manifest.revision !== expected.revision) return fail("stale");
|
|
51
|
+
const maps = new Map(expected.maps.map((map) => [map.id, map.fingerprint]));
|
|
52
|
+
if (maps.size !== expected.maps.length) throw new Error("Expected maps must have unique IDs");
|
|
53
|
+
if (manifest.images.length !== maps.size) return fail("missing");
|
|
54
|
+
for (const image of manifest.images) {
|
|
55
|
+
if (!maps.has(image.id)) return fail("invalid");
|
|
56
|
+
if (maps.get(image.id) !== image.fingerprint) return fail("stale");
|
|
57
|
+
}
|
|
58
|
+
const names = new Set(manifest.images.map((image) => geoMapSnapshotPart(image.id)));
|
|
59
|
+
for (const name of form.keys()) if (name.startsWith("map-snapshot-") && !names.has(name)) return fail("invalid");
|
|
60
|
+
const maximum = Math.min(expected.maxBytes, GEO_SNAPSHOT_MAX_BYTES);
|
|
61
|
+
let inputBytes = 0;
|
|
62
|
+
const uploads = manifest.images.map((image) => {
|
|
63
|
+
const parts = form.getAll(geoMapSnapshotPart(image.id));
|
|
64
|
+
if (parts.length !== 1 || !(parts[0] instanceof Blob)) return fail("missing");
|
|
65
|
+
const data = parts[0];
|
|
66
|
+
if (data.type !== "image/png") return fail("invalid");
|
|
67
|
+
inputBytes += data.size;
|
|
68
|
+
if (data.size < 33 || inputBytes > maximum) return fail("too_large");
|
|
69
|
+
return { image, data };
|
|
70
|
+
});
|
|
71
|
+
// Check every PNG IHDR before starting any expensive decoder. Filenames are ignored.
|
|
72
|
+
for (const { image, data } of uploads) {
|
|
73
|
+
check();
|
|
74
|
+
const header = new Uint8Array(await data.slice(0, 33).arrayBuffer());
|
|
75
|
+
const view = new DataView(header.buffer);
|
|
76
|
+
if (
|
|
77
|
+
![137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => header[index] === byte) ||
|
|
78
|
+
view.getUint32(8) !== 13 ||
|
|
79
|
+
view.getUint32(12) !== 0x49484452 ||
|
|
80
|
+
view.getUint32(16) !== image.width ||
|
|
81
|
+
view.getUint32(20) !== image.height
|
|
82
|
+
)
|
|
83
|
+
return fail("invalid");
|
|
84
|
+
}
|
|
85
|
+
const assets: GeoMapSnapshotAsset[] = [];
|
|
86
|
+
let outputBytes = 0;
|
|
87
|
+
for (const { image, data } of uploads) {
|
|
88
|
+
check();
|
|
89
|
+
const remaining = maximum - outputBytes;
|
|
90
|
+
if (remaining < 1) return fail("too_large");
|
|
91
|
+
const prepared = await preparePdfImage(
|
|
92
|
+
{ data, boxes: [{ width: image.width, height: image.height }], maxBytes: remaining },
|
|
93
|
+
{
|
|
94
|
+
signal: options.signal,
|
|
95
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
check();
|
|
99
|
+
if (!prepared.ok) return fail(prepared.code === "unsupported_format" ? "invalid" : prepared.code);
|
|
100
|
+
const variant = prepared.variants[0];
|
|
101
|
+
if (!variant) return fail("too_large");
|
|
102
|
+
if (variant.width !== image.width || variant.height !== image.height) return fail("invalid");
|
|
103
|
+
outputBytes += variant.data.size;
|
|
104
|
+
assets.push({ id: image.id, name: `geo-map-${assets.length}.${variant.type === "image/png" ? "png" : "jpg"}`, ...variant });
|
|
105
|
+
}
|
|
106
|
+
return assets;
|
|
107
|
+
}
|
|
@@ -648,11 +648,20 @@ export const session = {
|
|
|
648
648
|
|
|
649
649
|
revoke: revokeToken,
|
|
650
650
|
|
|
651
|
-
/** Signs out the web session (or an explicit bearer
|
|
652
|
-
delete: async (c: Context): Promise<
|
|
651
|
+
/** Signs out the web session (or an explicit bearer). False when an expected account no longer owns the request's credential. */
|
|
652
|
+
delete: async (c: Context, expectedUserId?: string | null): Promise<boolean> => {
|
|
653
|
+
// Capture once: another tab may replace the browser cookie while this
|
|
654
|
+
// request runs, but both the comparison and revocation concern this token.
|
|
653
655
|
const token = getWebOrBearerToken(c);
|
|
656
|
+
if (token && expectedUserId !== undefined) {
|
|
657
|
+
const claims = await verifySessionToken(token);
|
|
658
|
+
if (claims && claims.sub !== expectedUserId) return false;
|
|
659
|
+
}
|
|
654
660
|
if (token) await revokeToken(token, c.req.header("x-request-id"));
|
|
655
|
-
|
|
661
|
+
// Bound browser logout revokes the captured family without mutating the
|
|
662
|
+
// shared cookie: its response may arrive after another tab signs in.
|
|
663
|
+
if (expectedUserId === undefined) deleteCookie(c, "session_token", { path: "/" });
|
|
664
|
+
return true;
|
|
656
665
|
},
|
|
657
666
|
|
|
658
667
|
issueInTransaction,
|
package/src/shared/email-html.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import katex from "katex";
|
|
10
10
|
import type { MarkedExtension, Tokens } from "marked";
|
|
11
|
+
import { escapeHtml } from "../shared";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Token shape produced by the math tokenizers below. The marked extension API
|
|
@@ -40,7 +41,7 @@ function renderInlineMath(latex: string): string {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export function katexExtension(): MarkedExtension {
|
|
44
|
+
export function katexExtension(literal = false): MarkedExtension {
|
|
44
45
|
return {
|
|
45
46
|
extensions: [
|
|
46
47
|
// ```math code blocks
|
|
@@ -63,6 +64,10 @@ export function katexExtension(): MarkedExtension {
|
|
|
63
64
|
return undefined;
|
|
64
65
|
},
|
|
65
66
|
renderer(token: Tokens.Generic) {
|
|
67
|
+
if (literal) {
|
|
68
|
+
const source = token.raw.slice("```math\n".length, -"\n```".length);
|
|
69
|
+
return `<pre>${escapeHtml(source)}</pre>\n`;
|
|
70
|
+
}
|
|
66
71
|
const latex = (token as MathToken).latex;
|
|
67
72
|
return renderBlockMath(latex);
|
|
68
73
|
},
|
|
@@ -87,6 +92,7 @@ export function katexExtension(): MarkedExtension {
|
|
|
87
92
|
return undefined;
|
|
88
93
|
},
|
|
89
94
|
renderer(token: Tokens.Generic) {
|
|
95
|
+
if (literal) return `<p>${escapeHtml(token.raw)}</p>\n`;
|
|
90
96
|
const latex = (token as MathToken).latex;
|
|
91
97
|
return renderBlockMath(latex);
|
|
92
98
|
},
|
|
@@ -111,6 +117,7 @@ export function katexExtension(): MarkedExtension {
|
|
|
111
117
|
return undefined;
|
|
112
118
|
},
|
|
113
119
|
renderer(token: Tokens.Generic) {
|
|
120
|
+
if (literal) return escapeHtml(token.raw);
|
|
114
121
|
const latex = (token as MathToken).latex;
|
|
115
122
|
return renderInlineMath(latex);
|
|
116
123
|
},
|
|
@@ -66,11 +66,24 @@ const renderCell = (alignCls: string, originalText: string, ctx: EvalContext, ht
|
|
|
66
66
|
return `<td><span class="md-table-cell md-formula-error${alignCls}" title="${escapeHtml(tooltip)}">⚠ ${escapeHtml(originalText)}</span></td>`;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
export function tablesExtension(): MarkedExtension {
|
|
69
|
+
export function tablesExtension(plain = false): MarkedExtension {
|
|
70
70
|
return {
|
|
71
71
|
renderer: {
|
|
72
72
|
table(token: Tokens.Table): string {
|
|
73
73
|
const align = token.align ?? [];
|
|
74
|
+
// Email has no Cloud stylesheet or formula widgets. Keep alignment on the
|
|
75
|
+
// structural cells and escape formula source before Markdown can interpret it.
|
|
76
|
+
if (plain) {
|
|
77
|
+
const cellHtml = (cell: Tokens.TableCell, tag: "th" | "td", column: number): string => {
|
|
78
|
+
const alignment = align[column];
|
|
79
|
+
const attribute = alignment ? ` align="${alignment}"` : "";
|
|
80
|
+
const content = isFormula(cell.text) ? escapeHtml(cell.text) : this.parser.parseInline(cell.tokens);
|
|
81
|
+
return `<${tag}${attribute}>${content}</${tag}>`;
|
|
82
|
+
};
|
|
83
|
+
const header = token.header.map((cell, column) => cellHtml(cell, "th", column)).join("");
|
|
84
|
+
const rows = token.rows.map((row) => `<tr>${row.map((cell, column) => cellHtml(cell, "td", column)).join("")}</tr>`).join("");
|
|
85
|
+
return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
86
|
+
}
|
|
74
87
|
|
|
75
88
|
// Build the EvalContext once per table — formulas reference
|
|
76
89
|
// raw cell text, not other formulas' results, so a single pass
|