@k2b/cloud 0.40.0 → 0.42.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 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +158 -92
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/setup-lock.ts +103 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +76 -4
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +214 -23
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +64 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/http.ts +1 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +28 -0
- package/src/ai/open-questions.ts +168 -0
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +119 -35
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +184 -31
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +25 -4
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +73 -41
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/me.ts +79 -3
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +30 -66
- package/src/approvals/approval-card.ts +68 -0
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +149 -55
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +3 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +5 -3
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/accounts/users.ts +12 -1
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +15 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +72 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +11 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/session/index.ts +203 -29
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +185 -542
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +7 -5
- package/src/ssr/index.ts +2 -1
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-idle.ts +9 -0
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +15 -0
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Keep an app discoverable even when the ephemeral registry is recreated. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { APP_REGISTRY_TTL_MS, type AppInstanceRecord, appInstancePrefix } from "./registry";
|
|
4
6
|
|
|
5
7
|
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
6
8
|
const HEARTBEAT_WRITE_TIMEOUT_MS = 10_000;
|
|
@@ -15,8 +17,171 @@ type HeartbeatRegistry<T> = {
|
|
|
15
17
|
delete: (input: { key: string }) => Promise<unknown>;
|
|
16
18
|
};
|
|
17
19
|
|
|
20
|
+
type SnapshotRegistry<T> = {
|
|
21
|
+
snapshot: (input: { prefix: string }) => Promise<{ entries: { key: string; value: T }[] }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type MembershipRegistry = HeartbeatRegistry<AppInstanceRecord> & SnapshotRegistry<AppInstanceRecord>;
|
|
25
|
+
|
|
26
|
+
type SharedEntry = {
|
|
27
|
+
read: () => Promise<{ value: unknown } | undefined>;
|
|
28
|
+
write: () => Promise<unknown>;
|
|
29
|
+
remove: () => Promise<unknown>;
|
|
30
|
+
prepare: (() => Promise<void>) | undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const entryDigest = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("base64url");
|
|
34
|
+
|
|
35
|
+
type MembershipLock = {
|
|
36
|
+
withLock: <T>(
|
|
37
|
+
input: { resource: string; ttlMs?: number; signal?: AbortSignal },
|
|
38
|
+
fn: (lock: { expiresAt: Date }) => Promise<T>,
|
|
39
|
+
) => Promise<T | null>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** One process membership shared by its app and capability heartbeats. Getters stay lazy until use. */
|
|
43
|
+
export const createRegistryMembership = (
|
|
44
|
+
appId: string,
|
|
45
|
+
options: {
|
|
46
|
+
registry: () => MembershipRegistry;
|
|
47
|
+
lock: () => MembershipLock;
|
|
48
|
+
instanceId?: string;
|
|
49
|
+
hostname?: string;
|
|
50
|
+
startedAt?: number;
|
|
51
|
+
writeTimeoutMs?: number;
|
|
52
|
+
},
|
|
53
|
+
) => {
|
|
54
|
+
const instanceId = options.instanceId ?? randomBytes(12).toString("base64url");
|
|
55
|
+
const prefix = appInstancePrefix(appId);
|
|
56
|
+
const key = `${prefix}${instanceId}`;
|
|
57
|
+
const value: AppInstanceRecord = {
|
|
58
|
+
appId,
|
|
59
|
+
instanceId,
|
|
60
|
+
hostname: options.hostname ?? hostname(),
|
|
61
|
+
startedAt: options.startedAt ?? Date.now(),
|
|
62
|
+
entries: {},
|
|
63
|
+
};
|
|
64
|
+
const writeTimeoutMs = options.writeTimeoutMs ?? HEARTBEAT_WRITE_TIMEOUT_MS;
|
|
65
|
+
if (!Number.isFinite(writeTimeoutMs) || writeTimeoutMs <= 0) throw new RangeError("Membership write timeout must be positive and finite");
|
|
66
|
+
let left = false;
|
|
67
|
+
const sharedEntries = new Map<string, SharedEntry>();
|
|
68
|
+
|
|
69
|
+
// Prepare idempotent dependent data outside the lock to preserve its bounded lease.
|
|
70
|
+
const prepareShared = async (sharedKey: string): Promise<void> => {
|
|
71
|
+
for (const [key, { prepare }] of sharedEntries) {
|
|
72
|
+
if (key !== sharedKey) await prepare?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const locked = async (write: (lock: { expiresAt: Date }) => Promise<void>): Promise<void> => {
|
|
77
|
+
const result = await options
|
|
78
|
+
.lock()
|
|
79
|
+
.withLock({ resource: appId, ttlMs: writeTimeoutMs, signal: AbortSignal.timeout(writeTimeoutMs) }, async (lock) => {
|
|
80
|
+
await write(lock);
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
if (result === null) throw new Error(`Registry membership lock unavailable for app ${appId}`);
|
|
84
|
+
};
|
|
85
|
+
const upsert = () => options.registry().upsert({ key, value });
|
|
86
|
+
const publishShared = async (): Promise<void> => {
|
|
87
|
+
if (left) return;
|
|
88
|
+
await upsert();
|
|
89
|
+
// Never mix entries from different processes: the capability catalog rejects mismatched manifests.
|
|
90
|
+
for (const { write } of sharedEntries.values()) {
|
|
91
|
+
if (left) return;
|
|
92
|
+
await write();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
join: async (sharedKey: string, sharedValue: unknown, entry: SharedEntry): Promise<void> => {
|
|
98
|
+
left = false;
|
|
99
|
+
await prepareShared(sharedKey);
|
|
100
|
+
await locked(async () => {
|
|
101
|
+
value.entries[sharedKey] = entryDigest(sharedValue);
|
|
102
|
+
sharedEntries.set(sharedKey, entry);
|
|
103
|
+
await publishShared();
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
publish: async (sharedKey: string): Promise<void> => {
|
|
107
|
+
if (left) return;
|
|
108
|
+
await prepareShared(sharedKey);
|
|
109
|
+
await locked(async () => {
|
|
110
|
+
if (left) return;
|
|
111
|
+
await publishShared();
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
adopt: async (sharedKey: string): Promise<void> => {
|
|
115
|
+
if (left) return;
|
|
116
|
+
// A live process started after this one covers stored state when all read keys it declares match.
|
|
117
|
+
// Ignore keys it never registered, but heal split pairs when both keys are declared.
|
|
118
|
+
// Start order is total (instance id breaks ties), so two live replicas never take turns.
|
|
119
|
+
const startedLater = (other: AppInstanceRecord): boolean =>
|
|
120
|
+
other.startedAt > value.startedAt || (other.startedAt === value.startedAt && other.instanceId > value.instanceId);
|
|
121
|
+
const needsRepublish = async (): Promise<boolean> => {
|
|
122
|
+
const stored = await Promise.all(
|
|
123
|
+
[...sharedEntries].map(async ([key, { read }]) => {
|
|
124
|
+
const entry = await read();
|
|
125
|
+
return { key, digest: entry ? entryDigest(entry.value) : undefined };
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
if (stored.some(({ digest }) => digest === undefined)) return false;
|
|
129
|
+
if (stored.every(({ key, digest }) => digest === value.entries[key])) return false;
|
|
130
|
+
const snapshot = await options.registry().snapshot({ prefix });
|
|
131
|
+
return !snapshot.entries.some(
|
|
132
|
+
({ value }) =>
|
|
133
|
+
startedLater(value) &&
|
|
134
|
+
stored.some(({ key }) => key in value.entries) &&
|
|
135
|
+
stored.every(({ key, digest }) => !(key in value.entries) || value.entries[key] === digest),
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
if (!(await needsRepublish())) return;
|
|
139
|
+
await prepareShared(sharedKey);
|
|
140
|
+
await locked(async (lock) => {
|
|
141
|
+
if (left) return;
|
|
142
|
+
const stillNeedsRepublish = await needsRepublish();
|
|
143
|
+
if (left || !stillNeedsRepublish) return;
|
|
144
|
+
if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
|
|
145
|
+
throw new Error("membership lease expired during adoption; shared entry left to its TTL");
|
|
146
|
+
}
|
|
147
|
+
await publishShared();
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
renew: async (): Promise<void> => {
|
|
151
|
+
if (left) return;
|
|
152
|
+
if (!(await options.registry().touch({ key }))) {
|
|
153
|
+
await locked(async () => {
|
|
154
|
+
if (!left) await upsert();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
leave: async (): Promise<void> => {
|
|
159
|
+
left = true;
|
|
160
|
+
// A failed delete must never lead to shared removal: leave routing to its TTL.
|
|
161
|
+
// Repeat the delete even after leaving, to remove a late registration write.
|
|
162
|
+
await options.registry().delete({ key });
|
|
163
|
+
await locked(async (lock) => {
|
|
164
|
+
const snapshot = await options.registry().snapshot({ prefix });
|
|
165
|
+
// Ephemeral has no conditional delete, so the remaining lease bounds the removal window.
|
|
166
|
+
// A delete that NATS applies only after the lease expired can still remove a newer
|
|
167
|
+
// replica's entry; that replica restores it on its next refresh (at most 60 s).
|
|
168
|
+
if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
|
|
169
|
+
throw new Error("membership lease expired during stop; shared entry left to its TTL");
|
|
170
|
+
}
|
|
171
|
+
if (!snapshot.entries.some((entry) => entry.key !== key)) {
|
|
172
|
+
for (const { remove } of sharedEntries.values()) {
|
|
173
|
+
if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
|
|
174
|
+
throw new Error("membership lease expired during stop; shared entry left to its TTL");
|
|
175
|
+
}
|
|
176
|
+
await remove();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
18
184
|
type HeartbeatOptions<T> = {
|
|
19
|
-
registry: HeartbeatRegistry<T>;
|
|
20
185
|
key?: string;
|
|
21
186
|
/** Publish dependent data before advertising or renewing the app. */
|
|
22
187
|
beforeWrite?: () => Promise<void>;
|
|
@@ -26,7 +191,10 @@ type HeartbeatOptions<T> = {
|
|
|
26
191
|
writeTimeoutMs?: number;
|
|
27
192
|
onError?: (error: unknown) => void;
|
|
28
193
|
onStale?: (error: unknown) => void;
|
|
29
|
-
}
|
|
194
|
+
} & (
|
|
195
|
+
| { registry: HeartbeatRegistry<T>; membership?: undefined }
|
|
196
|
+
| { registry: HeartbeatRegistry<T> & SnapshotRegistry<T>; membership: ReturnType<typeof createRegistryMembership> }
|
|
197
|
+
);
|
|
30
198
|
|
|
31
199
|
export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOptions<T>) => {
|
|
32
200
|
const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
@@ -44,6 +212,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
|
|
|
44
212
|
let timer: Timer | null = null;
|
|
45
213
|
let inFlight: Promise<void> | null = null;
|
|
46
214
|
let running = false;
|
|
215
|
+
let needsMembershipCleanup = false;
|
|
47
216
|
let lastSuccessAt = 0;
|
|
48
217
|
let staleReported = false;
|
|
49
218
|
const key = options.key ?? `apps/${appId}`;
|
|
@@ -54,31 +223,49 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
|
|
|
54
223
|
staleReported = false;
|
|
55
224
|
};
|
|
56
225
|
|
|
226
|
+
const remove = () => (options.membership ? options.membership.leave() : registry.delete({ key }));
|
|
227
|
+
const upsert = () => registry.upsert({ key, value: entry });
|
|
228
|
+
|
|
57
229
|
const register = async (): Promise<void> => {
|
|
58
230
|
await options.beforeWrite?.();
|
|
59
|
-
|
|
231
|
+
if (options.membership) {
|
|
232
|
+
const registry = options.registry;
|
|
233
|
+
await options.membership.join(key, entry, {
|
|
234
|
+
read: async () => (await registry.snapshot({ prefix: key })).entries.find((candidate) => candidate.key === key),
|
|
235
|
+
write: upsert,
|
|
236
|
+
remove: () => registry.delete({ key }),
|
|
237
|
+
prepare: options.beforeWrite,
|
|
238
|
+
});
|
|
239
|
+
} else await upsert();
|
|
60
240
|
noteSuccess();
|
|
61
241
|
};
|
|
62
242
|
|
|
63
243
|
const refresh = async (): Promise<void> => {
|
|
64
244
|
// Touch renews the existing entry (and emits a registry invalidation).
|
|
65
245
|
// Avoid rebuilding the manifest here. A missing entry is repaired
|
|
66
|
-
// immediately, preserving restart-free registry recovery.
|
|
246
|
+
// immediately, preserving restart-free registry recovery. The newest live
|
|
247
|
+
// publisher wins; an entry whose publisher left is replaced by this one.
|
|
67
248
|
await options.beforeWrite?.();
|
|
249
|
+
await options.membership?.renew();
|
|
68
250
|
const touched = await registry.touch({ key });
|
|
69
|
-
if (!touched)
|
|
251
|
+
if (!touched) {
|
|
252
|
+
if (options.membership) await options.membership.publish(key);
|
|
253
|
+
else await upsert();
|
|
254
|
+
} else if (options.membership) {
|
|
255
|
+
await options.membership.adopt(key);
|
|
256
|
+
}
|
|
70
257
|
noteSuccess();
|
|
71
258
|
};
|
|
72
259
|
|
|
73
260
|
const writeWithTimeout = async (write: Promise<void>): Promise<void> => {
|
|
74
261
|
let timer: Timer | null = null;
|
|
75
262
|
let timedOut = false;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
263
|
+
// A write that settled after its timeout must not resurrect a stopped app. A failed
|
|
264
|
+
// operation may still have landed earlier steps, such as the liveness key.
|
|
265
|
+
const removeLateWrite = async () => {
|
|
266
|
+
if (timedOut && !running) await remove().catch(() => false);
|
|
267
|
+
};
|
|
268
|
+
void write.then(removeLateWrite, removeLateWrite).catch(() => undefined);
|
|
82
269
|
try {
|
|
83
270
|
await Promise.race([
|
|
84
271
|
write,
|
|
@@ -140,6 +327,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
|
|
|
140
327
|
return;
|
|
141
328
|
}
|
|
142
329
|
running = true;
|
|
330
|
+
needsMembershipCleanup = options.membership !== undefined;
|
|
143
331
|
const operation = writeWithTimeout(register());
|
|
144
332
|
inFlight = operation;
|
|
145
333
|
try {
|
|
@@ -153,7 +341,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
|
|
|
153
341
|
schedule(intervalMs);
|
|
154
342
|
},
|
|
155
343
|
stop: async () => {
|
|
156
|
-
if (!running) return;
|
|
344
|
+
if (!running && !needsMembershipCleanup) return;
|
|
157
345
|
running = false;
|
|
158
346
|
if (timer) clearTimeout(timer);
|
|
159
347
|
timer = null;
|
|
@@ -165,7 +353,8 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
|
|
|
165
353
|
// still required so a stopped app cannot remain discoverable.
|
|
166
354
|
}
|
|
167
355
|
inFlight = null;
|
|
168
|
-
await
|
|
356
|
+
await remove();
|
|
357
|
+
needsMembershipCleanup = false;
|
|
169
358
|
},
|
|
170
359
|
};
|
|
171
360
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
|
|
3
|
+
/** Matches the existing runtime worker drain deadline. */
|
|
4
|
+
export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
/** Bun supplies the listening server to fetch, including for default-export entrypoints. */
|
|
7
|
+
export const createHttpDrain = (options: { timeoutMs?: number } = {}) => {
|
|
8
|
+
type HttpServer = Pick<Server<unknown>, "stop">;
|
|
9
|
+
const isHttpServer = (value: unknown): value is HttpServer =>
|
|
10
|
+
typeof value === "object" && value !== null && "stop" in value && typeof value.stop === "function";
|
|
11
|
+
const servers = new Set<HttpServer>();
|
|
12
|
+
let stopping = false;
|
|
13
|
+
let draining: Promise<void> | undefined;
|
|
14
|
+
return {
|
|
15
|
+
get stopping() {
|
|
16
|
+
return stopping;
|
|
17
|
+
},
|
|
18
|
+
fetch: async (request: Request, transport: unknown, handle: (request: Request) => Response | Promise<Response>): Promise<Response> => {
|
|
19
|
+
const server = isHttpServer(transport) ? transport : undefined;
|
|
20
|
+
if (server) servers.add(server);
|
|
21
|
+
if (stopping) {
|
|
22
|
+
// A listener that had not served a request before the signal was not
|
|
23
|
+
// captured yet. Reject that first request and close it as well.
|
|
24
|
+
void server?.stop(false);
|
|
25
|
+
return Response.json({ status: "stopping" }, { status: 503, headers: { "Retry-After": "3", Connection: "close" } });
|
|
26
|
+
}
|
|
27
|
+
// Preserve Bun request identity for websocket upgrades, requestIP and
|
|
28
|
+
// per-request timeouts. Forced stop aborts the native request signal.
|
|
29
|
+
return handle(request);
|
|
30
|
+
},
|
|
31
|
+
stop: (): Promise<void> => {
|
|
32
|
+
stopping = true;
|
|
33
|
+
draining ??= (async () => {
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
let timedOut = false;
|
|
36
|
+
try {
|
|
37
|
+
await Promise.race([
|
|
38
|
+
Promise.all([...servers].map((server) => server.stop(false))),
|
|
39
|
+
new Promise<void>((resolve) => {
|
|
40
|
+
timer = setTimeout(() => {
|
|
41
|
+
// Cancel application work as well as the transport, before
|
|
42
|
+
// lifecycle hooks can tear down NATS or other dependencies.
|
|
43
|
+
timedOut = true;
|
|
44
|
+
resolve();
|
|
45
|
+
}, options.timeoutMs ?? HTTP_DRAIN_TIMEOUT_MS);
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
if (timedOut) await Promise.all([...servers].map((server) => server.stop(true)));
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
return draining;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
|
|
2
|
+
import { AppAccessRuleSchema } from "../contracts/app-access";
|
|
2
3
|
import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
|
|
3
4
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
4
5
|
import { isOwnAppPath, SAME_ORIGIN_PATH } from "./app-path";
|
|
@@ -28,6 +29,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
28
29
|
if (!isStringArray(value.routes) || value.routes.some((route) => !route.startsWith("/"))) {
|
|
29
30
|
return invalid("routes", "an array of absolute paths");
|
|
30
31
|
}
|
|
32
|
+
if (value.access !== undefined && !AppAccessRuleSchema.safeParse(value.access).success)
|
|
33
|
+
return invalid("access", "a declarative app access rule");
|
|
31
34
|
if (value.startedAt !== undefined && !(typeof value.startedAt === "number" && Number.isFinite(value.startedAt))) {
|
|
32
35
|
return invalid("startedAt", "a finite number");
|
|
33
36
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { EphemeralConfig, MutexConfig } from "@k2b/sync";
|
|
2
3
|
import type { AppAppearanceColor } from "../contracts/app";
|
|
3
4
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
4
5
|
import { fileProviderIssues } from "../contracts/file-provider";
|
|
@@ -45,6 +46,32 @@ export const CAPABILITY_REGISTRY_CONFIG = {
|
|
|
45
46
|
} as const satisfies EphemeralConfig;
|
|
46
47
|
|
|
47
48
|
export const APP_REGISTRY_PREFIX = "apps/";
|
|
49
|
+
export const APP_INSTANCE_PREFIX = "instances/";
|
|
50
|
+
|
|
51
|
+
// Fit Sync's 96-byte name limit for every app id that fits apps/<id>; appId stays in the record value.
|
|
52
|
+
export const appInstancePrefix = (appId: string): string => {
|
|
53
|
+
const segment = createHash("sha256").update(appId).digest("base64url").slice(0, 16);
|
|
54
|
+
return `${APP_INSTANCE_PREFIX}${segment}/`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type AppInstanceRecord = {
|
|
58
|
+
appId: string;
|
|
59
|
+
instanceId: string;
|
|
60
|
+
hostname: string;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
entries: Record<string, string>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// The id and owner are a cross-version contract: all replicas must use the same lock.
|
|
66
|
+
// TTL and retry are local budgets; Sync's drift identity is [id, owner, replicas].
|
|
67
|
+
export const APP_MEMBERSHIP_LOCK_CONFIG = {
|
|
68
|
+
id: "cloud-app-membership",
|
|
69
|
+
owner: REGISTRY_OWNER,
|
|
70
|
+
retry: { delayMs: 200, maxAttempts: 50 },
|
|
71
|
+
} as const satisfies MutexConfig;
|
|
72
|
+
|
|
73
|
+
export const appInstanceRegistry = lazySync((sync) => sync.ephemeral<AppInstanceRecord>(APP_REGISTRY_CONFIG));
|
|
74
|
+
export const appMembershipLock = lazySync((sync) => sync.mutex(APP_MEMBERSHIP_LOCK_CONFIG));
|
|
48
75
|
|
|
49
76
|
export type CapabilityRegistryRecord = { appId: string; manifest: CapabilityManifest; presentation?: CapabilityPresentationCatalog };
|
|
50
77
|
|
|
@@ -15,6 +15,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
15
15
|
const searchTags = searchQueries?.flatMap((query) => query.universalSearch!.tags.flatMap((tag) => [tag.tag, ...(tag.aliases ?? [])]));
|
|
16
16
|
return {
|
|
17
17
|
id: e.id,
|
|
18
|
+
access: e.access,
|
|
18
19
|
platformPermissions: e.platformPermissions ? [...e.platformPermissions] : undefined,
|
|
19
20
|
name: e.name,
|
|
20
21
|
icon: e.icon,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Mutex } from "@k2b/sync";
|
|
2
|
+
import { expBackoff } from "@k2b/sync/retry";
|
|
3
|
+
import { logger } from "../services/logging";
|
|
4
|
+
import { lazySync, waitForNats } from "./process-sync";
|
|
5
|
+
|
|
6
|
+
const SETUP_LEASE_MS = 60_000;
|
|
7
|
+
const setupMutex = lazySync((sync) => sync.mutex({ id: "app-setup", owner: "cloud", ttlMs: SETUP_LEASE_MS, retry: { maxAttempts: 1 } }));
|
|
8
|
+
|
|
9
|
+
const scheduleRenewal = (renew: () => Promise<void> | undefined, intervalMs: number): (() => void) => {
|
|
10
|
+
const timer = setInterval(() => void renew(), intervalMs);
|
|
11
|
+
timer.unref();
|
|
12
|
+
return () => clearInterval(timer);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Internal seam for testing lease timing without NATS or wall-clock waits. */
|
|
16
|
+
export const runExclusive = async ({
|
|
17
|
+
mutex,
|
|
18
|
+
resource,
|
|
19
|
+
run,
|
|
20
|
+
leaseMs = SETUP_LEASE_MS,
|
|
21
|
+
sleep = Bun.sleep,
|
|
22
|
+
now = Date.now,
|
|
23
|
+
schedule = scheduleRenewal,
|
|
24
|
+
log = logger("setup"),
|
|
25
|
+
}: {
|
|
26
|
+
mutex: Pick<Mutex, "ready" | "acquire" | "extend" | "release">;
|
|
27
|
+
resource: string;
|
|
28
|
+
run: () => Promise<void>;
|
|
29
|
+
leaseMs?: number;
|
|
30
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
schedule?: typeof scheduleRenewal;
|
|
33
|
+
log?: Pick<ReturnType<typeof logger>, "info" | "warn" | "error">;
|
|
34
|
+
}): Promise<void> => {
|
|
35
|
+
const startedAt = now();
|
|
36
|
+
const nats = { application: resource };
|
|
37
|
+
await waitForNats(() => mutex.ready(), nats);
|
|
38
|
+
let lastWaitLog = Number.NEGATIVE_INFINITY;
|
|
39
|
+
let attempt = 1;
|
|
40
|
+
let lock = await waitForNats(() => mutex.acquire({ resource }), nats);
|
|
41
|
+
while (!lock) {
|
|
42
|
+
if (now() - lastWaitLog >= 30_000) {
|
|
43
|
+
lastWaitLog = now();
|
|
44
|
+
log.info("waiting for setup on another replica", { appId: resource, waitedMs: now() - startedAt });
|
|
45
|
+
}
|
|
46
|
+
await sleep(Math.min(2_000, expBackoff(attempt++, { baseMs: 250, maxMs: 2_000, jitter: 0.2 })));
|
|
47
|
+
lock = await waitForNats(() => mutex.acquire({ resource }), nats);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lease = lock;
|
|
51
|
+
let lost = false;
|
|
52
|
+
let running = true;
|
|
53
|
+
let confirmedExpiresAt = lease.expiresAt.getTime();
|
|
54
|
+
let renewing: Promise<void> | undefined;
|
|
55
|
+
const checkExpiry = () => {
|
|
56
|
+
if (running && now() >= confirmedExpiresAt) lost = true;
|
|
57
|
+
};
|
|
58
|
+
const cancelRenewal = schedule(() => {
|
|
59
|
+
// Check even when an earlier extend is still in flight or the event loop was delayed.
|
|
60
|
+
checkExpiry();
|
|
61
|
+
if (renewing || lost) return;
|
|
62
|
+
renewing = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const extended = await mutex.extend(lease);
|
|
65
|
+
// extend mutates the handle; check the previous confirmed expiry first.
|
|
66
|
+
checkExpiry();
|
|
67
|
+
if (extended) confirmedExpiresAt = lease.expiresAt.getTime();
|
|
68
|
+
else lost = true;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
log.warn("setup lease renewal failed", { appId: resource, error });
|
|
71
|
+
checkExpiry();
|
|
72
|
+
}
|
|
73
|
+
})().finally(() => {
|
|
74
|
+
renewing = undefined;
|
|
75
|
+
});
|
|
76
|
+
return renewing;
|
|
77
|
+
}, leaseMs / 3);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
try {
|
|
81
|
+
await run();
|
|
82
|
+
} finally {
|
|
83
|
+
checkExpiry();
|
|
84
|
+
running = false;
|
|
85
|
+
cancelRenewal();
|
|
86
|
+
// Do not race a release against an unfinished renewal.
|
|
87
|
+
await renewing;
|
|
88
|
+
if (lost) {
|
|
89
|
+
log.error("setup lease lost; another replica may have run setup concurrently; restarting so setup reruns", { appId: resource });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (lost) throw new Error(`Setup lease lost for application "${resource}"`);
|
|
93
|
+
} finally {
|
|
94
|
+
try {
|
|
95
|
+
if (!(await mutex.release(lease))) log.warn("setup lease release failed; it will expire", { appId: resource });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
log.warn("setup lease release failed; it will expire", { appId: resource, error });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const runSetupExclusively = (appId: string, setup: () => Promise<void>): Promise<void> =>
|
|
103
|
+
runExclusive({ mutex: setupMutex(), resource: appId, run: setup });
|
|
@@ -7,6 +7,7 @@ import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
|
7
7
|
import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
|
|
8
8
|
import type { User } from "../contracts/shared";
|
|
9
9
|
import type { CORE_SETTINGS } from "../services/settings/core-settings";
|
|
10
|
+
import { preloadDisplayApps } from "../ssr/runtime";
|
|
10
11
|
import { measureServerPhase } from "./server-timing";
|
|
11
12
|
|
|
12
13
|
type PageEnv<T extends object> = {
|
|
@@ -45,6 +46,7 @@ export const createStatusPreservingSsrHandler = <T extends object>(
|
|
|
45
46
|
const handler = args[args.length - 1] as SsrHandler<E, T>;
|
|
46
47
|
|
|
47
48
|
return createHandler<E>(...middlewares, async (context) => {
|
|
49
|
+
await preloadDisplayApps(context);
|
|
48
50
|
const result = await measureServerPhase(context, "ssr_data", () => handler(context as Context<E & PageEnv<T>>));
|
|
49
51
|
if (result instanceof Response) return result;
|
|
50
52
|
await measureServerPhase(context, "ssr_finalize", () => finalizePage?.(context as unknown as Context<PageEnv<T>>));
|
|
@@ -4,11 +4,11 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "so
|
|
|
4
4
|
import { createStore, reconcile } from "solid-js/store";
|
|
5
5
|
import { CloudAvatar } from "../account/Avatar";
|
|
6
6
|
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
7
|
-
import { groupDisplayName } from "../shared/account-display";
|
|
8
7
|
import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
|
|
9
8
|
import { isManagerEntry } from "./managers";
|
|
10
9
|
import { accessMessages } from "./messages";
|
|
11
10
|
import PrincipalPicker, { principalKey } from "./PrincipalPicker";
|
|
11
|
+
import { principalLabel } from "./principal-label";
|
|
12
12
|
import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
13
13
|
|
|
14
14
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -43,7 +43,7 @@ type PermissionEditorProps = {
|
|
|
43
43
|
grantAccess: (
|
|
44
44
|
principal: Principal,
|
|
45
45
|
permission: GrantableLevel,
|
|
46
|
-
display?: { displayName: string; serviceAccountKind?: ServiceAccountKind },
|
|
46
|
+
display?: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
|
|
47
47
|
scope?: string,
|
|
48
48
|
) => Promise<AccessEntry>;
|
|
49
49
|
|
|
@@ -145,16 +145,6 @@ const resolveEntryDisplay = (
|
|
|
145
145
|
return defaults[permission] ?? defaults.none;
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
-
const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
|
|
149
|
-
// Audiences always use the localized label; a server-supplied name is not translated.
|
|
150
|
-
if (entry.principal.type === "authenticated") return t.allUsers;
|
|
151
|
-
if (entry.principal.type === "public") return t.public;
|
|
152
|
-
if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
|
|
153
|
-
if (entry.principal.type === "user") return entry.principal.userId;
|
|
154
|
-
if (entry.principal.type === "service_account") return entry.principal.serviceAccountId;
|
|
155
|
-
return entry.principal.groupId;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
148
|
const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
|
|
159
149
|
switch (entry.principal.type) {
|
|
160
150
|
case "user":
|
|
@@ -204,7 +194,10 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
204
194
|
}
|
|
205
195
|
}
|
|
206
196
|
|
|
207
|
-
const grant = async (
|
|
197
|
+
const grant = async (
|
|
198
|
+
principal: Principal,
|
|
199
|
+
display: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
|
|
200
|
+
) => {
|
|
208
201
|
if (disposed || grantPending() || updateMut.loading() || revokeMut.loading()) return;
|
|
209
202
|
const key = principalKey(principal);
|
|
210
203
|
if (entries.some((entry) => principalKey(entry.principal) === key)) return;
|
|
@@ -224,8 +217,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
224
217
|
const entry = await props.grantAccess(principal, first.level, display, first.scope);
|
|
225
218
|
if (disposed) return;
|
|
226
219
|
const index = entries.findIndex((entry) => entry.id === id);
|
|
227
|
-
|
|
228
|
-
|
|
220
|
+
const saved = {
|
|
221
|
+
...entry,
|
|
222
|
+
displayName: entry.displayName ?? display.displayName,
|
|
223
|
+
avatarHash: entry.avatarHash !== undefined ? entry.avatarHash : display.avatarHash,
|
|
224
|
+
serviceAccountKind: entry.serviceAccountKind ?? display.serviceAccountKind,
|
|
225
|
+
};
|
|
226
|
+
if (index !== -1) setEntries(index, reconcile(saved));
|
|
227
|
+
announce(t().accessGranted({ name: principalLabel(saved, locale()) }));
|
|
229
228
|
} catch (err) {
|
|
230
229
|
if (!disposed) setEntries((current) => current.filter((entry) => entry.id !== id));
|
|
231
230
|
// Prompts are global, so a grant that fails after its dialog closed still says so.
|
|
@@ -264,7 +263,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
264
263
|
|
|
265
264
|
const revokeMut = mutation.create<string | null, AccessEntry>({
|
|
266
265
|
mutation: async (entry) => {
|
|
267
|
-
const displayName =
|
|
266
|
+
const displayName = principalLabel(entry, locale());
|
|
268
267
|
const confirmed = await prompts.confirm(t().removeAccessConfirm({ name: displayName }), {
|
|
269
268
|
title: t().removeAccess,
|
|
270
269
|
variant: "danger",
|
|
@@ -395,7 +394,7 @@ function AccessEntryRow(props: {
|
|
|
395
394
|
}) {
|
|
396
395
|
const locale = useLocale();
|
|
397
396
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
398
|
-
const displayName = () =>
|
|
397
|
+
const displayName = () => principalLabel(props.entry, locale());
|
|
399
398
|
const entryKey = () => levelKey(props.entry.permission, props.entry.scope);
|
|
400
399
|
const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
|
|
401
400
|
const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
|