@k2b/cloud 0.39.0 → 0.40.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 +8 -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 +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/runtime-watcher.ts +6 -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 +398 -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 +669 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -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 +190 -39
- package/src/ai/index.ts +2 -0
- package/src/ai/migrate.ts +11 -2
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +145 -1
- 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 +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- 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.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth.ts +25 -6
- 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 +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- 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 +180 -238
- 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 +120 -13
- 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/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -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/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- 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 +11 -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/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- 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/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -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/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 +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +2 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -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/effects.css +198 -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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type StandaloneServiceAccountKind,
|
|
12
12
|
serviceAccounts,
|
|
13
13
|
} from "./service-accounts";
|
|
14
|
+
import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
14
15
|
|
|
15
16
|
export type ServiceAccountCredentialStatus = "active" | "revoked";
|
|
16
17
|
export type ServiceAccountCredentialKind = "api_token";
|
|
@@ -388,10 +389,27 @@ export const createApiToken = async (params: {
|
|
|
388
389
|
|
|
389
390
|
export const createUserApiToken = async (params: {
|
|
390
391
|
user: User;
|
|
392
|
+
browserSignIn: { userId: string; sid: string };
|
|
391
393
|
access?: "full" | "models";
|
|
392
394
|
name: string;
|
|
393
395
|
expiresAt?: string | null;
|
|
394
396
|
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
397
|
+
const confirmSignIn = async (db: SQL): Promise<ReturnType<typeof fail> | null> => {
|
|
398
|
+
if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
|
|
399
|
+
try {
|
|
400
|
+
await requireConfirmedBrowserSignIn(db, params.browserSignIn, (code) => {
|
|
401
|
+
throw new ConfirmedBrowserSignInError(code);
|
|
402
|
+
});
|
|
403
|
+
return null;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Checked before the key holder is created and again where the key is written.
|
|
411
|
+
const early = await confirmSignIn(sql);
|
|
412
|
+
if (early) return early;
|
|
395
413
|
const serviceAccountResult = await getOrCreateUserDelegatedServiceAccount({
|
|
396
414
|
userId: params.user.id,
|
|
397
415
|
createdBy: params.user.id,
|
|
@@ -399,6 +417,9 @@ export const createUserApiToken = async (params: {
|
|
|
399
417
|
if (!serviceAccountResult.ok) return fail(serviceAccountResult.error);
|
|
400
418
|
|
|
401
419
|
return sql.begin(async (tx) => {
|
|
420
|
+
const rejected = await confirmSignIn(tx);
|
|
421
|
+
if (rejected) return rejected;
|
|
422
|
+
|
|
402
423
|
const result = await insertApiToken(tx, {
|
|
403
424
|
serviceAccountId: serviceAccountResult.data.id,
|
|
404
425
|
name: params.name,
|
|
@@ -5,6 +5,8 @@ import { HTTPException } from "hono/http-exception";
|
|
|
5
5
|
import { env } from "../../config/env";
|
|
6
6
|
import { PWA_AUTH_PATH, PWA_COOKIES } from "../../contracts/pwa";
|
|
7
7
|
import type { User } from "../../contracts/shared";
|
|
8
|
+
import { getLocale } from "../../server/locale";
|
|
9
|
+
import { describeRequest } from "../../server/request-context";
|
|
8
10
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
9
11
|
import { isAccountExpired } from "../account-model";
|
|
10
12
|
import { audit } from "../audit";
|
|
@@ -12,6 +14,17 @@ import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "..
|
|
|
12
14
|
import { invalidateIdentitySignerCache, type PreparedIdentitySigner, prepareIdentitySigner } from "../identity/key-ring";
|
|
13
15
|
import { getIdentityRuntimeConfig } from "../identity/runtime-config";
|
|
14
16
|
import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
|
|
17
|
+
import {
|
|
18
|
+
announceNewBrowser,
|
|
19
|
+
type NewBrowserSignInNotice,
|
|
20
|
+
type NewBrowserSignInNotifier,
|
|
21
|
+
newBrowserCookie,
|
|
22
|
+
noticeFirstName,
|
|
23
|
+
readBrowserCookie,
|
|
24
|
+
rememberSignIn,
|
|
25
|
+
type SignInMethod,
|
|
26
|
+
setBrowserCookie,
|
|
27
|
+
} from "../known-browsers";
|
|
15
28
|
import * as settings from "../settings";
|
|
16
29
|
import { loadJwtSession, loadJwtSessionIdentity } from "./user";
|
|
17
30
|
|
|
@@ -144,12 +157,24 @@ const revokeToken = async (token: string, requestId?: string | null): Promise<vo
|
|
|
144
157
|
}
|
|
145
158
|
};
|
|
146
159
|
|
|
160
|
+
export class SessionEpochChangedError extends Error {}
|
|
161
|
+
/** The Cloud Login device that approved a sign-in was removed before the session was issued. */
|
|
162
|
+
export class SessionAppDeviceInactiveError extends Error {}
|
|
163
|
+
|
|
147
164
|
export type SessionIssueOptions = {
|
|
165
|
+
authEpoch?: number;
|
|
148
166
|
ttlSeconds: number;
|
|
167
|
+
/** Only web session.create supplies browser persistence, never mobile issuance. */
|
|
168
|
+
knownBrowser?: Pick<
|
|
169
|
+
Parameters<typeof rememberSignIn>[1],
|
|
170
|
+
"cookie" | "browser" | "os" | "country" | "city" | "method" | "device" | "locale" | "announce"
|
|
171
|
+
>;
|
|
149
172
|
/** Pre-chosen family id, for callers that record it in the same transaction. */
|
|
150
173
|
sid?: string;
|
|
151
174
|
/** Binds the family to a paired phone of the mobile app; the device must be active. */
|
|
152
175
|
pwaDeviceId?: string | null;
|
|
176
|
+
/** The active Cloud Login device that approved this web sign-in. */
|
|
177
|
+
appDeviceId?: string;
|
|
153
178
|
/** Web sign-ins update `last_login_local`; app renewals track use per device instead. */
|
|
154
179
|
recordLogin?: boolean;
|
|
155
180
|
};
|
|
@@ -162,12 +187,14 @@ type PreparedFamily = {
|
|
|
162
187
|
expiresAt: Date;
|
|
163
188
|
signer: PreparedIdentitySigner;
|
|
164
189
|
options: SessionIssueOptions;
|
|
190
|
+
notice: NewBrowserSignInNotice | null;
|
|
165
191
|
};
|
|
166
192
|
|
|
167
193
|
const SIGNER_INACTIVE = "Prepared Cloud session signer is no longer active";
|
|
168
194
|
const isSignerInactive = (error: unknown) => error instanceof Error && error.message === SIGNER_INACTIVE;
|
|
169
195
|
|
|
170
|
-
/** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows
|
|
196
|
+
/** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows; `NO KEY UPDATE`
|
|
197
|
+
* serializes with every other account lock but lets foreign-key checks of concurrent writes pass. */
|
|
171
198
|
const insertFamily = async (
|
|
172
199
|
tx: SQL,
|
|
173
200
|
c: Context,
|
|
@@ -175,19 +202,22 @@ const insertFamily = async (
|
|
|
175
202
|
signer: PreparedIdentitySigner,
|
|
176
203
|
options: SessionIssueOptions,
|
|
177
204
|
): Promise<PreparedFamily> => {
|
|
178
|
-
const issuedAt = new Date();
|
|
179
|
-
const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
|
|
180
205
|
const sid = options.sid ?? crypto.randomUUID();
|
|
181
206
|
const requestId = c.req.header("x-request-id")?.slice(0, 64) ?? null;
|
|
182
207
|
const userAgent = c.req.header("user-agent")?.slice(0, 256) ?? null;
|
|
183
|
-
const [user] = await tx<
|
|
184
|
-
|
|
208
|
+
const [user] = await tx<
|
|
209
|
+
Array<{ given_name: string | null; auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>
|
|
210
|
+
>`
|
|
211
|
+
SELECT given_name, auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
185
212
|
`;
|
|
186
213
|
if (!user) throw new Error("Cannot create a session for an unknown user");
|
|
214
|
+
const issuedAt = new Date();
|
|
215
|
+
const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
|
|
187
216
|
if (!(await isAccountCategoryAllowed(user, tx)))
|
|
188
217
|
throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
|
|
189
218
|
const epoch = Number(user.auth_epoch);
|
|
190
219
|
if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
|
|
220
|
+
if (options.authEpoch !== undefined && epoch !== options.authEpoch) throw new SessionEpochChangedError();
|
|
191
221
|
if (options.pwaDeviceId) {
|
|
192
222
|
// Device revocation locks the same user row first, so a revoked device never gets a new family.
|
|
193
223
|
const [device] = await tx`
|
|
@@ -195,6 +225,14 @@ const insertFamily = async (
|
|
|
195
225
|
`;
|
|
196
226
|
if (!device) throw new Error("Cannot create an app session for an inactive device");
|
|
197
227
|
}
|
|
228
|
+
if (options.appDeviceId) {
|
|
229
|
+
// A device that decides a terminal sign-in holds its row and then takes a key-share lock on this user row, so the
|
|
230
|
+
// user row above is held without blocking key-share locks. Removals that end the device's sign-ins hold the user
|
|
231
|
+
// row too, so they wait for this family.
|
|
232
|
+
const [device] = await tx`SELECT id FROM auth.app_devices
|
|
233
|
+
WHERE id=${options.appDeviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL`;
|
|
234
|
+
if (!device) throw new SessionAppDeviceInactiveError("Cannot create a session for an inactive sign-in device");
|
|
235
|
+
}
|
|
198
236
|
const [activeKey] = await tx<Array<{ kid: string }>>`
|
|
199
237
|
UPDATE auth.signing_keys
|
|
200
238
|
SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
|
|
@@ -204,12 +242,15 @@ const insertFamily = async (
|
|
|
204
242
|
if (!activeKey) throw new Error(SIGNER_INACTIVE);
|
|
205
243
|
await tx`
|
|
206
244
|
INSERT INTO auth.session_families (
|
|
207
|
-
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id
|
|
245
|
+
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
|
|
208
246
|
) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
|
|
209
|
-
NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null})
|
|
247
|
+
NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null}, ${options.appDeviceId ?? null})
|
|
210
248
|
`;
|
|
249
|
+
const notice = options.knownBrowser
|
|
250
|
+
? await rememberSignIn(tx, { ...options.knownBrowser, userId, firstName: noticeFirstName(user.given_name), now: issuedAt })
|
|
251
|
+
: null;
|
|
211
252
|
if (options.recordLogin !== false) await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
|
|
212
|
-
return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options };
|
|
253
|
+
return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options, notice };
|
|
213
254
|
};
|
|
214
255
|
|
|
215
256
|
/** Signs a committed family. Null when the signer was revoked meanwhile; the family is revoked then. */
|
|
@@ -244,7 +285,13 @@ const signFamily = async (family: PreparedFamily): Promise<string | null> => {
|
|
|
244
285
|
};
|
|
245
286
|
|
|
246
287
|
/** One issuance attempt is retried once when the signing key changes underneath it. */
|
|
247
|
-
const issueJwtSession = async (
|
|
288
|
+
const issueJwtSession = async (
|
|
289
|
+
c: Context,
|
|
290
|
+
userId: string,
|
|
291
|
+
options: SessionIssueOptions,
|
|
292
|
+
attempt = 0,
|
|
293
|
+
committedNotice: NewBrowserSignInNotice | null = null,
|
|
294
|
+
): Promise<{ token: string; notice: NewBrowserSignInNotice | null }> => {
|
|
248
295
|
const signer = await prepareIdentitySigner("session");
|
|
249
296
|
let family: PreparedFamily;
|
|
250
297
|
try {
|
|
@@ -252,13 +299,14 @@ const issueJwtSession = async (c: Context, userId: string, options: SessionIssue
|
|
|
252
299
|
} catch (error) {
|
|
253
300
|
if (attempt === 0 && isSignerInactive(error)) {
|
|
254
301
|
invalidateIdentitySignerCache("session", signer.kid);
|
|
255
|
-
return issueJwtSession(c, userId, options, 1);
|
|
302
|
+
return issueJwtSession(c, userId, options, 1, committedNotice);
|
|
256
303
|
}
|
|
257
304
|
throw error;
|
|
258
305
|
}
|
|
259
306
|
const token = await signFamily(family);
|
|
260
|
-
|
|
261
|
-
if (
|
|
307
|
+
const notice = committedNotice ?? family.notice;
|
|
308
|
+
if (token) return { token, notice };
|
|
309
|
+
if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
|
|
262
310
|
throw new Error("Cloud session signer changed repeatedly during issuance");
|
|
263
311
|
};
|
|
264
312
|
|
|
@@ -281,7 +329,7 @@ const issueInTransaction = async <T>(
|
|
|
281
329
|
value = await sql.begin((tx) =>
|
|
282
330
|
work(tx, async (userId, options) => {
|
|
283
331
|
if (family) throw new Error("Only one session family per transaction");
|
|
284
|
-
family = await insertFamily(tx, c, userId, signer, options);
|
|
332
|
+
family = await insertFamily(tx, c, userId, signer, { ...options, knownBrowser: undefined });
|
|
285
333
|
return family.sid;
|
|
286
334
|
}),
|
|
287
335
|
);
|
|
@@ -294,7 +342,7 @@ const issueInTransaction = async <T>(
|
|
|
294
342
|
}
|
|
295
343
|
if (!family) return { value, token: null };
|
|
296
344
|
const token = await signFamily(family);
|
|
297
|
-
return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)) };
|
|
345
|
+
return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)).token };
|
|
298
346
|
}
|
|
299
347
|
};
|
|
300
348
|
|
|
@@ -349,11 +397,43 @@ export const session = {
|
|
|
349
397
|
|
|
350
398
|
getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
|
|
351
399
|
|
|
352
|
-
create: async (
|
|
400
|
+
create: async (
|
|
401
|
+
c: Context,
|
|
402
|
+
userId: string,
|
|
403
|
+
signIn: {
|
|
404
|
+
method: SignInMethod;
|
|
405
|
+
device?: string | null;
|
|
406
|
+
/** Null means this sign-in announces nothing, including during maintenance. */
|
|
407
|
+
notify: NewBrowserSignInNotifier | null;
|
|
408
|
+
/** Account epoch an earlier approval was bound to; issuance fails when sign-out everywhere moved it. */
|
|
409
|
+
authEpoch?: number;
|
|
410
|
+
/** The Cloud Login device that approved this sign-in; removing the device can end the session. */
|
|
411
|
+
appDeviceId?: string;
|
|
412
|
+
},
|
|
413
|
+
): Promise<string> => {
|
|
353
414
|
const expiryHours = await settings.get<number>("user.session.expiry_hours");
|
|
354
415
|
if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
|
|
355
416
|
const ttlSeconds = Math.floor(expiryHours * 60 * 60);
|
|
356
|
-
const
|
|
417
|
+
const cookie = readBrowserCookie(c) ?? newBrowserCookie();
|
|
418
|
+
const { browser, os, country, city } = describeRequest(c);
|
|
419
|
+
const { token: clientToken, notice } = await issueJwtSession(c, userId, {
|
|
420
|
+
ttlSeconds,
|
|
421
|
+
authEpoch: signIn.authEpoch,
|
|
422
|
+
appDeviceId: signIn.appDeviceId,
|
|
423
|
+
knownBrowser: {
|
|
424
|
+
cookie,
|
|
425
|
+
browser,
|
|
426
|
+
os,
|
|
427
|
+
country,
|
|
428
|
+
city,
|
|
429
|
+
method: signIn.method,
|
|
430
|
+
device: signIn.device ?? null,
|
|
431
|
+
locale: getLocale(c),
|
|
432
|
+
announce: signIn.notify !== null,
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
// Handoff precedes the response so the person signing in cannot turn the notice off first.
|
|
436
|
+
if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
|
|
357
437
|
|
|
358
438
|
setCookie(c, "session_token", clientToken, {
|
|
359
439
|
httpOnly: true,
|
|
@@ -362,6 +442,7 @@ export const session = {
|
|
|
362
442
|
maxAge: ttlSeconds,
|
|
363
443
|
path: "/",
|
|
364
444
|
});
|
|
445
|
+
setBrowserCookie(c, cookie);
|
|
365
446
|
return clientToken;
|
|
366
447
|
},
|
|
367
448
|
|
|
@@ -11,6 +11,15 @@ export type EligibleAccount = {
|
|
|
11
11
|
account_expires: Date | null;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
/** New personal access requires a browser sign-in from the last ten minutes. */
|
|
15
|
+
export const LASTING_ACCESS_SIGN_IN_SECONDS = 600;
|
|
16
|
+
|
|
17
|
+
export class ConfirmedBrowserSignInError extends Error {
|
|
18
|
+
constructor(readonly code: "REAUTHENTICATE" | "FORBIDDEN") {
|
|
19
|
+
super(code === "REAUTHENTICATE" ? "Sign in again to confirm it is you." : "This account cannot create new access.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
type Reject = (code: "REAUTHENTICATE" | "FORBIDDEN") => never;
|
|
15
24
|
|
|
16
25
|
/** The account exists, has not expired and its category is enabled. */
|
|
@@ -40,3 +49,7 @@ export const requireRecentWebSession = async (
|
|
|
40
49
|
if (!row) return reject("REAUTHENTICATE");
|
|
41
50
|
return requireEligibleAccount(tx, actor.userId, reject);
|
|
42
51
|
};
|
|
52
|
+
|
|
53
|
+
/** Checks the browser session inside the transaction that creates personal access. */
|
|
54
|
+
export const requireConfirmedBrowserSignIn = (tx: SQL, signIn: { userId: string; sid: string }, reject: Reject): Promise<EligibleAccount> =>
|
|
55
|
+
requireRecentWebSession(tx, signIn, LASTING_ACCESS_SIGN_IN_SECONDS, reject);
|
package/src/services/webauthn.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { accounts } from "./accounts";
|
|
|
16
16
|
import { audit } from "./audit";
|
|
17
17
|
import { logger } from "./logging";
|
|
18
18
|
import { isUniqueViolation, toPgTextArray } from "./postgres";
|
|
19
|
+
import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
19
20
|
import { coreSettings } from "./settings/api";
|
|
20
21
|
|
|
21
22
|
const CHALLENGE_TTL_SECONDS = 300;
|
|
@@ -185,6 +186,7 @@ export const beginRegistration = async (params: { user: User }): Promise<Result<
|
|
|
185
186
|
|
|
186
187
|
export const finishRegistration = async (params: {
|
|
187
188
|
user: User;
|
|
189
|
+
browserSignIn: { userId: string; sid: string };
|
|
188
190
|
name: string;
|
|
189
191
|
response: RegistrationResponseJSON;
|
|
190
192
|
}): Promise<Result<WebAuthnPasskey>> => {
|
|
@@ -221,6 +223,16 @@ export const finishRegistration = async (params: {
|
|
|
221
223
|
|
|
222
224
|
try {
|
|
223
225
|
return sql.begin(async (tx) => {
|
|
226
|
+
if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
|
|
227
|
+
try {
|
|
228
|
+
await requireConfirmedBrowserSignIn(tx, params.browserSignIn, (code) => {
|
|
229
|
+
throw new ConfirmedBrowserSignInError(code);
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
224
236
|
const [row] = await tx<DbPasskeyRow[]>`
|
|
225
237
|
INSERT INTO auth.webauthn_credentials (
|
|
226
238
|
user_id,
|
package/src/shared/email-html.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const EMAIL_HTML_TAGS = [
|
|
|
10
10
|
"h1",
|
|
11
11
|
"h2",
|
|
12
12
|
"h3",
|
|
13
|
+
"hr",
|
|
13
14
|
"li",
|
|
14
15
|
"ol",
|
|
15
16
|
"p",
|
|
@@ -33,6 +34,7 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
|
|
33
34
|
h1: ["style"],
|
|
34
35
|
h2: ["style"],
|
|
35
36
|
h3: ["style"],
|
|
37
|
+
hr: ["style"],
|
|
36
38
|
li: ["style"],
|
|
37
39
|
ol: ["style"],
|
|
38
40
|
p: ["style"],
|
package/src/shared/help.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { localeFallbackChain } from "./locale";
|
|
2
2
|
|
|
3
|
+
/** Maximum number of page-specific Help articles shared with the panel. */
|
|
4
|
+
export const HELP_VIEW_LIMIT = 3;
|
|
5
|
+
|
|
3
6
|
export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
|
|
4
7
|
export type HelpKind = (typeof HELP_KINDS)[number];
|
|
5
8
|
/** The glossary's access levels; Help names only these, never app-specific permissions. */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const STREAM_CURSOR_REGEX = /^s6t\.[A-Za-z0-9_-]+\.\d+$/;
|
|
2
|
+
|
|
3
|
+
export const parseStreamCursor = (cursor: string | null | undefined): { resource: string; seq: number } | null => {
|
|
4
|
+
if (!cursor || !STREAM_CURSOR_REGEX.test(cursor)) return null;
|
|
5
|
+
const [, resource, sequence] = cursor.split(".");
|
|
6
|
+
const seq = Number(sequence);
|
|
7
|
+
if (!resource || !Number.isSafeInteger(seq) || seq < 0) return null;
|
|
8
|
+
return { resource, seq };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const compareStreamCursor = (left: string, right: string): number => {
|
|
12
|
+
const l = parseStreamCursor(left);
|
|
13
|
+
const r = parseStreamCursor(right);
|
|
14
|
+
if (!l || !r || l.resource !== r.resource) throw new Error(`Invalid stream cursor comparison: "${left}" vs "${right}"`);
|
|
15
|
+
return l.seq - r.seq;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const maxStreamCursor = (a: string | null, b: string | null): string | null => {
|
|
19
|
+
if (!a) return b;
|
|
20
|
+
if (!b) return a;
|
|
21
|
+
return compareStreamCursor(a, b) >= 0 ? a : b;
|
|
22
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Topic } from "@k2b/sync";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { compareStreamCursor } from "./cursor";
|
|
4
|
+
import { SHARED_TEXT_NAME, sharedTextNodeId, toBase64 } from "./events";
|
|
5
|
+
import { minimalTextDiff } from "./text-diff";
|
|
6
|
+
import type {
|
|
7
|
+
SharedTextActor,
|
|
8
|
+
SharedTextContributor,
|
|
9
|
+
SharedTextEditResult,
|
|
10
|
+
SharedTextSnapshot,
|
|
11
|
+
SharedTextStore,
|
|
12
|
+
SharedTextStoredState,
|
|
13
|
+
SharedTextSyncEvent,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
type DocumentLog = {
|
|
17
|
+
topic(): Pick<Topic<SharedTextSyncEvent>, "latestCursor" | "publish">;
|
|
18
|
+
replay(config: {
|
|
19
|
+
docId: string;
|
|
20
|
+
after: string | null;
|
|
21
|
+
until: string;
|
|
22
|
+
doc: Y.Doc;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<SharedTextContributor[]>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fallback text becomes the same Yjs items in every copy: an update published
|
|
29
|
+
* against one copy then applies to the next one, which a random client ID
|
|
30
|
+
* would leave pending and silently drop from the saved state.
|
|
31
|
+
*/
|
|
32
|
+
const FALLBACK_CLIENT_ID = 0;
|
|
33
|
+
|
|
34
|
+
const createDoc = (stored: SharedTextStoredState): Y.Doc => {
|
|
35
|
+
const doc = new Y.Doc({ gc: true });
|
|
36
|
+
try {
|
|
37
|
+
if (stored.state) Y.applyUpdate(doc, stored.state, "snapshot");
|
|
38
|
+
else if (stored.text) {
|
|
39
|
+
const clientID = doc.clientID;
|
|
40
|
+
doc.clientID = FALLBACK_CLIENT_ID;
|
|
41
|
+
doc.getText(SHARED_TEXT_NAME).insert(0, stored.text);
|
|
42
|
+
doc.clientID = clientID;
|
|
43
|
+
}
|
|
44
|
+
return doc;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
doc.destroy();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const bindDocuments = <Saved, Context>({ topic, replay }: DocumentLog, store: SharedTextStore<Saved, Context>) => {
|
|
52
|
+
return {
|
|
53
|
+
async read(docId: string, options: { signal?: AbortSignal } = {}): Promise<SharedTextSnapshot | null> {
|
|
54
|
+
const stored = await store.load(docId, options);
|
|
55
|
+
if (!stored) return null;
|
|
56
|
+
const latest = await topic().latestCursor({ tenantId: docId });
|
|
57
|
+
if (!latest || (stored.cursor && compareStreamCursor(latest, stored.cursor) <= 0))
|
|
58
|
+
return { state: stored.state, text: stored.text, cursor: stored.cursor };
|
|
59
|
+
const doc = createDoc(stored);
|
|
60
|
+
try {
|
|
61
|
+
await replay({ docId, after: stored.cursor, until: latest, doc, signal: options.signal });
|
|
62
|
+
return { state: Y.encodeStateAsUpdate(doc), text: doc.getText(SHARED_TEXT_NAME).toString(), cursor: latest };
|
|
63
|
+
} finally {
|
|
64
|
+
doc.destroy();
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async edit(
|
|
68
|
+
docId: string,
|
|
69
|
+
options: { actor: SharedTextActor | null; context?: Context; signal?: AbortSignal },
|
|
70
|
+
update: (current: { text: string; contributors: SharedTextContributor[]; state(): Uint8Array }) => string | Promise<string>,
|
|
71
|
+
): Promise<SharedTextEditResult<Saved>> {
|
|
72
|
+
const requestedAt = Date.now();
|
|
73
|
+
const stored = await store.load(docId, { signal: options.signal });
|
|
74
|
+
if (!stored) return { status: "notFound" };
|
|
75
|
+
const handle = topic();
|
|
76
|
+
const target = await handle.latestCursor({ tenantId: docId });
|
|
77
|
+
const doc = createDoc(stored);
|
|
78
|
+
try {
|
|
79
|
+
let contributors: SharedTextContributor[] = [];
|
|
80
|
+
try {
|
|
81
|
+
if (target) contributors = await replay({ docId, after: stored.cursor, until: target, doc, signal: options.signal });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A cancelled request is not a sync failure: it throws like every other abort before publication.
|
|
84
|
+
options.signal?.throwIfAborted();
|
|
85
|
+
return { status: "syncFailed", error };
|
|
86
|
+
}
|
|
87
|
+
options.signal?.throwIfAborted();
|
|
88
|
+
const ytext = doc.getText(SHARED_TEXT_NAME);
|
|
89
|
+
const text = ytext.toString();
|
|
90
|
+
const next = await update({ text, contributors, state: () => Y.encodeStateAsUpdate(doc) });
|
|
91
|
+
if (next === text) return { status: "unchanged", text };
|
|
92
|
+
// A request cancelled while the callback ran must not publish a write.
|
|
93
|
+
options.signal?.throwIfAborted();
|
|
94
|
+
const diff = minimalTextDiff(text, next);
|
|
95
|
+
const beforeEditState = Y.encodeStateVector(doc);
|
|
96
|
+
doc.transact(() => {
|
|
97
|
+
if (diff.deleteCount > 0) ytext.delete(diff.index, diff.deleteCount);
|
|
98
|
+
if (diff.insert.length > 0) ytext.insert(diff.index, diff.insert);
|
|
99
|
+
}, "cli-edit");
|
|
100
|
+
const actor = options.actor;
|
|
101
|
+
const published = await handle.publish({
|
|
102
|
+
tenantId: docId,
|
|
103
|
+
data: {
|
|
104
|
+
kind: "sync",
|
|
105
|
+
payload: toBase64(Y.encodeStateAsUpdate(doc, beforeEditState)),
|
|
106
|
+
originNodeId: sharedTextNodeId,
|
|
107
|
+
originPeerId: null,
|
|
108
|
+
...(actor ? { actor } : {}),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
let persistedDoc: Y.Doc | undefined;
|
|
112
|
+
try {
|
|
113
|
+
persistedDoc = createDoc(stored);
|
|
114
|
+
await replay({ docId, after: stored.cursor, until: published.cursor, doc: persistedDoc, signal: options.signal });
|
|
115
|
+
const persistedText = persistedDoc.getText(SHARED_TEXT_NAME).toString();
|
|
116
|
+
const credited = new Map(contributors.map((contributor) => [`${contributor.kind}:${contributor.id}`, contributor]));
|
|
117
|
+
if (actor) credited.set(`${actor.kind}:${actor.id}`, { ...actor, lastContributedAt: new Date(requestedAt) });
|
|
118
|
+
const saved = await store.save(
|
|
119
|
+
docId,
|
|
120
|
+
{
|
|
121
|
+
state: Y.encodeStateAsUpdate(persistedDoc),
|
|
122
|
+
text: persistedText,
|
|
123
|
+
cursor: published.cursor,
|
|
124
|
+
restoreRevision: stored.restoreRevision,
|
|
125
|
+
requestedAt,
|
|
126
|
+
actor,
|
|
127
|
+
contributors: [...credited.values()],
|
|
128
|
+
},
|
|
129
|
+
options.context,
|
|
130
|
+
);
|
|
131
|
+
return { status: "edited", text: persistedText, cursor: published.cursor, saved };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { status: "persistFailed", cursor: published.cursor, error };
|
|
134
|
+
} finally {
|
|
135
|
+
persistedDoc?.destroy();
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
doc.destroy();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fromBase64Strict } from "@k2b/stdlib";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import type { SharedTextEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
export const SHARED_TEXT_NAME = "codemirror";
|
|
6
|
+
export const sharedTextNodeId = crypto.randomUUID();
|
|
7
|
+
export const toBase64 = (data: Uint8Array): string => Buffer.from(data).toString("base64");
|
|
8
|
+
export const fromBase64 = fromBase64Strict;
|
|
9
|
+
|
|
10
|
+
/** A retained event cannot be decoded as a Yjs update: retrying never helps, no replay can pass it. */
|
|
11
|
+
export class MalformedSharedTextEventError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly docId: string,
|
|
14
|
+
readonly cursor: string,
|
|
15
|
+
cause: unknown,
|
|
16
|
+
) {
|
|
17
|
+
super(`Malformed sync event for document ${docId} at cursor ${cursor}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
18
|
+
this.name = "MalformedSharedTextEventError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Ingress check: a publish is accepted only when it is base64 of a decodable Yjs update. */
|
|
23
|
+
export const isValidSharedTextUpdate = (payload: string): boolean => {
|
|
24
|
+
try {
|
|
25
|
+
Y.decodeUpdate(fromBase64(payload));
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const applySharedTextEvent = (doc: Y.Doc, event: { cursor: string; data: SharedTextEvent }, docId: string): void => {
|
|
33
|
+
if (event.data.kind !== "sync") return;
|
|
34
|
+
try {
|
|
35
|
+
const update = fromBase64(event.data.payload);
|
|
36
|
+
// Decode completely before mutating the document: malformed bytes must not
|
|
37
|
+
// partially integrate state before recovery skips the invalid event.
|
|
38
|
+
Y.decodeUpdate(update);
|
|
39
|
+
Y.applyUpdate(doc, update, `replay:${event.cursor}`);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new MalformedSharedTextEventError(docId, event.cursor, error);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { compareStreamCursor, maxStreamCursor, parseStreamCursor } from "./cursor";
|
|
2
|
+
export {
|
|
3
|
+
applySharedTextEvent,
|
|
4
|
+
fromBase64,
|
|
5
|
+
isValidSharedTextUpdate,
|
|
6
|
+
MalformedSharedTextEventError,
|
|
7
|
+
SHARED_TEXT_NAME,
|
|
8
|
+
sharedTextNodeId,
|
|
9
|
+
toBase64,
|
|
10
|
+
} from "./events";
|
|
11
|
+
export { sharedText } from "./log";
|
|
12
|
+
export * from "./types";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TopicConfig } from "@k2b/sync";
|
|
2
|
+
import type * as Y from "yjs";
|
|
3
|
+
import { getProcessSync } from "../_internal/process-sync";
|
|
4
|
+
import { compareStreamCursor } from "./cursor";
|
|
5
|
+
import { bindDocuments } from "./documents";
|
|
6
|
+
import { applySharedTextEvent } from "./events";
|
|
7
|
+
import type { SharedTextContributor, SharedTextStore, SharedTextSyncEvent } from "./types";
|
|
8
|
+
|
|
9
|
+
const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "deadLetterRetention" | "maxPayloadBytes">>) => {
|
|
10
|
+
const topic = () => getProcessSync().topic<SharedTextSyncEvent>(topicConfig);
|
|
11
|
+
const replay = async (config: {
|
|
12
|
+
docId: string;
|
|
13
|
+
after: string | null;
|
|
14
|
+
until: string;
|
|
15
|
+
doc: Y.Doc;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}): Promise<SharedTextContributor[]> => {
|
|
18
|
+
const contributors = new Map<string, SharedTextContributor>();
|
|
19
|
+
const handle = topic();
|
|
20
|
+
const after = config.after ?? handle.cursorAt(0);
|
|
21
|
+
if (compareStreamCursor(after, config.until) >= 0) return [];
|
|
22
|
+
let reachedTarget = false;
|
|
23
|
+
for await (const event of handle.replay({ tenantId: config.docId, after, until: config.until, signal: config.signal })) {
|
|
24
|
+
applySharedTextEvent(config.doc, event, config.docId);
|
|
25
|
+
if (event.data.actor)
|
|
26
|
+
contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
|
|
27
|
+
...event.data.actor,
|
|
28
|
+
lastContributedAt: event.publishedAt,
|
|
29
|
+
});
|
|
30
|
+
reachedTarget = event.cursor === config.until;
|
|
31
|
+
}
|
|
32
|
+
if (!reachedTarget) throw new Error(`Target cursor "${config.until}" was not reached during replay`);
|
|
33
|
+
return [...contributors.values()];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
id: topicConfig.id,
|
|
38
|
+
topicConfig,
|
|
39
|
+
topic,
|
|
40
|
+
replay,
|
|
41
|
+
withStore<Saved, Context>(store: SharedTextStore<Saved, Context>) {
|
|
42
|
+
return bindDocuments({ topic, replay }, store);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Server-only document logs. Applications authorize callers before read/edit. */
|
|
48
|
+
export const sharedText = { define };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const splitsSurrogatePair = (text: string, index: number): boolean => {
|
|
2
|
+
const before = text.charCodeAt(index - 1);
|
|
3
|
+
const after = text.charCodeAt(index);
|
|
4
|
+
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** One replacement retaining the shared prefix and suffix at Unicode boundaries. */
|
|
8
|
+
export const minimalTextDiff = (before: string, after: string): { index: number; deleteCount: number; insert: string } => {
|
|
9
|
+
let index = 0;
|
|
10
|
+
while (index < before.length && index < after.length && before[index] === after[index]) index++;
|
|
11
|
+
if (splitsSurrogatePair(before, index) || splitsSurrogatePair(after, index)) index--;
|
|
12
|
+
|
|
13
|
+
let suffix = 0;
|
|
14
|
+
while (
|
|
15
|
+
suffix < before.length - index &&
|
|
16
|
+
suffix < after.length - index &&
|
|
17
|
+
before[before.length - suffix - 1] === after[after.length - suffix - 1]
|
|
18
|
+
)
|
|
19
|
+
suffix++;
|
|
20
|
+
if (splitsSurrogatePair(before, before.length - suffix) || splitsSurrogatePair(after, after.length - suffix)) suffix--;
|
|
21
|
+
|
|
22
|
+
return { index, deleteCount: before.length - index - suffix, insert: after.slice(index, after.length - suffix) };
|
|
23
|
+
};
|