@k2b/cloud 0.39.0 → 0.41.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/README.md +8 -0
- package/package.json +11 -4
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +217 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +49 -18
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/setup-lock.ts +103 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +464 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +680 -0
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +456 -0
- package/src/ai/chat/turn-view.tsx +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +198 -39
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +3 -0
- package/src/ai/migrate.ts +26 -2
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +195 -5
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +245 -24
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +36 -8
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +43 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +2 -0
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +67 -12
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +6 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +30 -3
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +159 -36
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +166 -262
- package/src/approvals/approval-card.ts +68 -0
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +123 -16
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +3 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +13 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +49 -31
- package/src/services/approvals/index.ts +7 -5
- package/src/services/approvals/list.ts +27 -6
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +13 -0
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +296 -41
- package/src/services/session/recent.ts +13 -0
- package/src/services/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +10 -2
- 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 +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +222 -419
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +21 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -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-start.ts +33 -7
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/ssr/panel-slot.browser-harness.tsx +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +213 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +17 -22
|
@@ -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
|
};
|
|
@@ -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
|
|
|
@@ -19,6 +19,7 @@ import { buildRuntimeFromRegistry } from "./runtime-context";
|
|
|
19
19
|
const log = logger("runtime-watcher");
|
|
20
20
|
|
|
21
21
|
let current: CloudRuntime | undefined;
|
|
22
|
+
let coreRelease: string | undefined;
|
|
22
23
|
let initPromise: Promise<void> | undefined;
|
|
23
24
|
let watcherTask: Promise<void> | undefined;
|
|
24
25
|
let abort: AbortController | undefined;
|
|
@@ -27,8 +28,12 @@ const refresh = async () => {
|
|
|
27
28
|
const apps = await listApps();
|
|
28
29
|
const capabilities = await listCapabilities(apps);
|
|
29
30
|
current = buildRuntimeFromRegistry(apps, capabilities);
|
|
31
|
+
coreRelease = apps.find((app) => app.id === "core")?.runtime?.release;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
/** Core's release from the live registry, absent before initialization and after shutdown. */
|
|
35
|
+
export const registeredCoreRelease = (): string | undefined => coreRelease;
|
|
36
|
+
|
|
32
37
|
export const ensureRuntimeWatcher = (): Promise<void> => {
|
|
33
38
|
// Concurrent callers (start() + first request) wait on the same init —
|
|
34
39
|
// returning before `current` is populated would race getCurrentRuntime().
|
|
@@ -68,6 +73,7 @@ export const stopRuntimeWatcher = async (): Promise<void> => {
|
|
|
68
73
|
watcherTask = undefined;
|
|
69
74
|
initPromise = undefined;
|
|
70
75
|
current = undefined;
|
|
76
|
+
coreRelease = undefined;
|
|
71
77
|
};
|
|
72
78
|
|
|
73
79
|
export const getCurrentRuntime = (): CloudRuntime => {
|
|
@@ -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 });
|
|
@@ -1,7 +1,43 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
1
3
|
import { resolve, sep } from "node:path";
|
|
2
4
|
import type { Context } from "hono";
|
|
3
5
|
|
|
4
6
|
const publicRoot = resolve(process.cwd(), "public");
|
|
7
|
+
const versions = new Map<string, { size: number; mtimeMs: number; version: string }>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Version of Core's shared stylesheets: the release, which names one build. `local` and `development` are the
|
|
11
|
+
* defaults of unnamed builds and name many, so those sheets get no version and are revalidated instead.
|
|
12
|
+
*/
|
|
13
|
+
export function sharedStylesheetVersion(release: string | undefined): string | undefined {
|
|
14
|
+
return !release || release === "local" || release === "development" ? undefined : release;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Content version of a file below `public/`: hashed on first use and re-hashed only when the file's size or
|
|
19
|
+
* modification time changes; a missing or unreadable file has no version.
|
|
20
|
+
*/
|
|
21
|
+
export function publicFileVersion(pathname: string): string | undefined {
|
|
22
|
+
const path = resolvePublicAsset(pathname);
|
|
23
|
+
if (!path) return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const stat = statSync(path, { throwIfNoEntry: false });
|
|
26
|
+
if (!stat?.isFile()) return undefined;
|
|
27
|
+
const cached = versions.get(path);
|
|
28
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.version;
|
|
29
|
+
const version = createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
|
|
30
|
+
versions.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, version });
|
|
31
|
+
return version;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Append a URL-encoded version only when the asset has one. */
|
|
38
|
+
export function versionedPublicHref(pathname: string, version: string | undefined): string {
|
|
39
|
+
return version === undefined ? pathname : `${pathname}?v=${encodeURIComponent(version)}`;
|
|
40
|
+
}
|
|
5
41
|
|
|
6
42
|
function decodePathname(pathname: string): string | null {
|
|
7
43
|
try {
|
|
@@ -48,14 +84,15 @@ async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.B
|
|
|
48
84
|
return (await file.exists()) ? file : null;
|
|
49
85
|
}
|
|
50
86
|
|
|
51
|
-
export function servePublicAsset(isDevelopment: boolean) {
|
|
87
|
+
export function servePublicAsset(isDevelopment: boolean, sharedVersion?: string) {
|
|
52
88
|
return async (c: Context) => {
|
|
53
89
|
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
54
90
|
c.header("Allow", "GET, HEAD");
|
|
55
91
|
return c.text("Method Not Allowed", 405);
|
|
56
92
|
}
|
|
57
93
|
|
|
58
|
-
const
|
|
94
|
+
const url = new URL(c.req.url);
|
|
95
|
+
const path = resolvePublicAsset(url.pathname);
|
|
59
96
|
if (!path) return c.notFound();
|
|
60
97
|
|
|
61
98
|
const sourceFile = Bun.file(path);
|
|
@@ -84,8 +121,20 @@ export function servePublicAsset(isDevelopment: boolean) {
|
|
|
84
121
|
// Development rebuilds files in place under the same address, so the browser asks again on every use; an
|
|
85
122
|
// unchanged file then costs one round trip instead of its whole size on every page.
|
|
86
123
|
const etag = `"${selectedEncoding ?? "identity"}-${selected.size.toString(36)}-${selected.lastModified.toString(36)}"`;
|
|
124
|
+
// The page head links these files with the version this process names for them. Any other version may name
|
|
125
|
+
// other content (a rolling deploy, an app started before a Core redeploy), so it is revalidated, never kept.
|
|
126
|
+
let cacheControl = isDevelopment ? "no-cache" : "public, max-age=31536000, immutable";
|
|
127
|
+
if (!isDevelopment) {
|
|
128
|
+
const pathname = decodePathname(url.pathname)!;
|
|
129
|
+
const shared = /^\/public\/(fonts|tabler-icons|global)\.css$/.test(pathname);
|
|
130
|
+
const own = /^\/public\/[^/]+\/(app\.css|favicon\.svg)$/.test(pathname);
|
|
131
|
+
if (shared || own) {
|
|
132
|
+
const expected = shared ? sharedVersion : publicFileVersion(url.pathname);
|
|
133
|
+
if (expected === undefined || url.searchParams.get("v") !== expected) cacheControl = "no-cache";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
87
136
|
const headers = new Headers({
|
|
88
|
-
"Cache-Control":
|
|
137
|
+
"Cache-Control": cacheControl,
|
|
89
138
|
ETag: etag,
|
|
90
139
|
Vary: "Accept-Encoding",
|
|
91
140
|
});
|
|
@@ -2,6 +2,7 @@ import type { HtmlFn, RenderFn } from "@k2b/ssr";
|
|
|
2
2
|
import { createSSRHandler } from "@k2b/ssr/hono";
|
|
3
3
|
import type { Context, Env, MiddlewareHandler, TypedResponse } from "hono";
|
|
4
4
|
import type { StatusCode } from "hono/utils/http-status";
|
|
5
|
+
import type { PanelConsentSnapshot } from "../contracts/panel-context";
|
|
5
6
|
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
6
7
|
import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
|
|
7
8
|
import type { User } from "../contracts/shared";
|
|
@@ -13,6 +14,7 @@ type PageEnv<T extends object> = {
|
|
|
13
14
|
page: Partial<T>;
|
|
14
15
|
user?: User;
|
|
15
16
|
railPreferences?: RailSnapshot;
|
|
17
|
+
panelConsent?: PanelConsentSnapshot;
|
|
16
18
|
settings?: Unflatten<SettingsFlat<typeof CORE_SETTINGS>>;
|
|
17
19
|
};
|
|
18
20
|
};
|
|
@@ -425,7 +425,7 @@ function AccessEntryRow(props: {
|
|
|
425
425
|
<Show
|
|
426
426
|
when={props.entry.principal.type === "user"}
|
|
427
427
|
fallback={
|
|
428
|
-
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-
|
|
428
|
+
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-secondary dark:bg-zinc-700">
|
|
429
429
|
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
430
430
|
</div>
|
|
431
431
|
}
|
|
@@ -509,7 +509,7 @@ function AccessEntryRow(props: {
|
|
|
509
509
|
aria-label={t().remove({ name: displayName() })}
|
|
510
510
|
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
511
511
|
aria-description={props.lastManager ? removeLabel() : undefined}
|
|
512
|
-
class="focus-ui flex h-7 w-7 items-center justify-center rounded text-
|
|
512
|
+
class="focus-ui flex h-7 w-7 items-center justify-center rounded text-dimmed transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
|
|
513
513
|
>
|
|
514
514
|
<i class="ti ti-x text-sm" />
|
|
515
515
|
</IconButton>
|
|
@@ -350,7 +350,7 @@ const ResultRow = (props: {
|
|
|
350
350
|
<Show
|
|
351
351
|
when={props.avatar}
|
|
352
352
|
fallback={
|
|
353
|
-
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-
|
|
353
|
+
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-secondary dark:bg-zinc-700">
|
|
354
354
|
<i class={`ti ${props.icon} text-sm`} />
|
|
355
355
|
</div>
|
|
356
356
|
}
|