@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.
Files changed (194) hide show
  1. package/README.md +8 -0
  2. package/package.json +8 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +216 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +36 -15
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/runtime-watcher.ts +6 -0
  12. package/src/_internal/static-assets.ts +52 -3
  13. package/src/_internal/status-preserving-ssr.ts +2 -0
  14. package/src/access/PermissionEditor.tsx +2 -2
  15. package/src/account/EntitySearch.tsx +1 -1
  16. package/src/ai/action-review.ts +101 -0
  17. package/src/ai/background-review.ts +72 -0
  18. package/src/ai/capabilities.ts +29 -11
  19. package/src/ai/capability-execution.ts +76 -43
  20. package/src/ai/capability-result.ts +95 -0
  21. package/src/ai/chat/action-card.ts +398 -0
  22. package/src/ai/chat/action-messages.ts +129 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  24. package/src/ai/chat/action-question.fixtures.ts +733 -0
  25. package/src/ai/chat/action-question.tsx +669 -0
  26. package/src/ai/chat/blocks.tsx +19 -25
  27. package/src/ai/chat/message-actions.tsx +6 -0
  28. package/src/ai/chat/tool-action.tsx +481 -0
  29. package/src/ai/chat/turn-view.tsx +10 -160
  30. package/src/ai/chat/view-block.tsx +15 -5
  31. package/src/ai/chat-task-contracts.ts +4 -1
  32. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  33. package/src/ai/chat-task-reasons.ts +1 -1
  34. package/src/ai/chat-tasks.ts +55 -25
  35. package/src/ai/client/controller.ts +59 -0
  36. package/src/ai/default-tools.ts +9 -1
  37. package/src/ai/executor.ts +190 -39
  38. package/src/ai/index.ts +2 -0
  39. package/src/ai/migrate.ts +11 -2
  40. package/src/ai/projects-routes.ts +3 -0
  41. package/src/ai/protocol.ts +14 -2
  42. package/src/ai/recent-resources.ts +240 -0
  43. package/src/ai/recent-sent-refs.ts +38 -0
  44. package/src/ai/routes.ts +145 -1
  45. package/src/ai/runtime.ts +1 -0
  46. package/src/ai/skills-routes.ts +3 -0
  47. package/src/ai/skills.ts +10 -1
  48. package/src/ai/store.ts +227 -24
  49. package/src/ai/tool-audit.ts +8 -0
  50. package/src/ai/tool-wording.ts +24 -2
  51. package/src/ai/types.ts +28 -3
  52. package/src/api/accounts-entities.ts +2 -1
  53. package/src/api/admin-ai-projects.ts +2 -0
  54. package/src/api/admin-ai-skills.ts +2 -0
  55. package/src/api/admin-ai-usage.ts +2 -0
  56. package/src/api/admin-geoip.ts +80 -0
  57. package/src/api/admin-identity.ts +3 -0
  58. package/src/api/admin-lifecycle.ts +3 -0
  59. package/src/api/admin-outgoing-mail.ts +2 -0
  60. package/src/api/app-approval.ts +46 -5
  61. package/src/api/approvals-loader.ts +23 -1
  62. package/src/api/approvals.ts +2 -0
  63. package/src/api/auth.ts +25 -6
  64. package/src/api/capabilities.ts +44 -11
  65. package/src/api/capability-streams.ts +12 -0
  66. package/src/api/help.ts +4 -28
  67. package/src/api/index.ts +6 -1
  68. package/src/api/lasting-access-messages.ts +17 -0
  69. package/src/api/mcp.ts +3 -0
  70. package/src/api/me-panel-consent.ts +71 -0
  71. package/src/api/me.ts +80 -33
  72. package/src/api/search.ts +3 -1
  73. package/src/api/sync-ops.ts +2 -0
  74. package/src/api/widgets.ts +3 -0
  75. package/src/approvals/Approvals.tsx +180 -238
  76. package/src/approvals/messages.ts +12 -24
  77. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  78. package/src/browser/app-approval.ts +54 -5
  79. package/src/browser/commands.ts +22 -6
  80. package/src/browser/composer-catalog.ts +45 -3
  81. package/src/browser/composer-insert.ts +10 -0
  82. package/src/browser/composer-open-messages.ts +17 -0
  83. package/src/browser/composer-open.ts +110 -0
  84. package/src/browser/discoverable-command.ts +6 -0
  85. package/src/browser/hints.ts +6 -0
  86. package/src/browser/live-connection.ts +26 -2
  87. package/src/browser/navigation-apps.ts +7 -0
  88. package/src/browser/panel-context-bridge.ts +30 -0
  89. package/src/browser/panel-context-receiver.ts +53 -0
  90. package/src/browser/panel-menu.tsx +56 -0
  91. package/src/browser/panel-messages.ts +11 -0
  92. package/src/browser/panel-shortcuts.ts +31 -0
  93. package/src/browser/panel-slot.ts +412 -10
  94. package/src/browser/panel.ts +37 -0
  95. package/src/browser/search-commands.ts +6 -3
  96. package/src/capabilities/client.ts +8 -2
  97. package/src/capabilities/reviews.ts +205 -0
  98. package/src/capabilities/server.ts +10 -3
  99. package/src/capabilities/testing.ts +47 -0
  100. package/src/capabilities/types.ts +2 -2
  101. package/src/cli/account.ts +13 -3
  102. package/src/cli/admin/geoip.ts +115 -0
  103. package/src/cli/admin/index.ts +3 -0
  104. package/src/contracts/app-approval.ts +120 -13
  105. package/src/contracts/approvals.ts +2 -2
  106. package/src/contracts/capabilities.ts +410 -18
  107. package/src/contracts/geoip.ts +42 -0
  108. package/src/contracts/index.ts +3 -0
  109. package/src/contracts/panel-context-plain.ts +99 -0
  110. package/src/contracts/panel-context.ts +93 -0
  111. package/src/contracts/seen-hints.ts +35 -0
  112. package/src/server/client-ip.ts +5 -0
  113. package/src/server/help-payload.ts +38 -0
  114. package/src/server/middleware/auth.ts +11 -1
  115. package/src/server/middleware/middleware.ts +9 -0
  116. package/src/server/middleware/no-transform.ts +19 -0
  117. package/src/server/middleware/rate-limit.ts +4 -6
  118. package/src/server/request-context/address.ts +45 -0
  119. package/src/server/request-context/geoip.ts +380 -0
  120. package/src/server/request-context/index.ts +33 -0
  121. package/src/server/request-context/user-agent.ts +95 -0
  122. package/src/services/app-approval.ts +382 -59
  123. package/src/services/approvals/device.ts +7 -5
  124. package/src/services/approvals/index.ts +6 -5
  125. package/src/services/approvals/list.ts +26 -5
  126. package/src/services/auth-flows/notification-sender.ts +3 -0
  127. package/src/services/geoip/index.ts +164 -0
  128. package/src/services/geoip/store.ts +30 -0
  129. package/src/services/geoip/upload.ts +150 -0
  130. package/src/services/identity/routes.ts +5 -1
  131. package/src/services/index.ts +11 -0
  132. package/src/services/known-browsers.ts +312 -0
  133. package/src/services/login-address-store.ts +51 -0
  134. package/src/services/login-context.ts +118 -0
  135. package/src/services/mandates/index.ts +3 -0
  136. package/src/services/mandates/policy.ts +15 -3
  137. package/src/services/notifications/email-frame.ts +1 -1
  138. package/src/services/oauth-tokens.ts +16 -2
  139. package/src/services/panel-context-consent.ts +58 -0
  140. package/src/services/service-account-credentials.ts +21 -0
  141. package/src/services/session/index.ts +97 -16
  142. package/src/services/session/recent.ts +13 -0
  143. package/src/services/webauthn.ts +12 -0
  144. package/src/shared/email-html.ts +2 -0
  145. package/src/shared/help.ts +3 -0
  146. package/src/shared-text/cursor.ts +22 -0
  147. package/src/shared-text/documents.ts +142 -0
  148. package/src/shared-text/events.ts +43 -0
  149. package/src/shared-text/index.ts +12 -0
  150. package/src/shared-text/log.ts +48 -0
  151. package/src/shared-text/text-diff.ts +23 -0
  152. package/src/shared-text/types.ts +41 -0
  153. package/src/ssr/AdminLayout.tsx +2 -0
  154. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  155. package/src/ssr/CloudShell.island.tsx +10 -2
  156. package/src/ssr/Layout.tsx +25 -2
  157. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  158. package/src/ssr/LayoutHelp.tsx +435 -274
  159. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  160. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  161. package/src/ssr/MobileNavigation.tsx +2 -2
  162. package/src/ssr/PanelLayout.tsx +9 -1
  163. package/src/ssr/PanelRuntime.island.tsx +7 -0
  164. package/src/ssr/PanelSlot.tsx +53 -3
  165. package/src/ssr/ShellRuntime.island.tsx +11 -2
  166. package/src/ssr/app-color.ts +14 -9
  167. package/src/ssr/app-navigation.ts +16 -3
  168. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  169. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  170. package/src/ssr/help-messages.ts +4 -0
  171. package/src/ssr/help.ts +20 -0
  172. package/src/ssr/hints.ts +25 -0
  173. package/src/ssr/index.ts +2 -0
  174. package/src/ssr/islands/index.ts +4 -3
  175. package/src/ssr/layout-context.ts +2 -0
  176. package/src/ssr/layout-help-article.server.ts +50 -0
  177. package/src/ssr/layout-help-start.ts +33 -7
  178. package/src/ssr/panel-consent.ts +29 -0
  179. package/src/ssr/panel-context.ts +10 -0
  180. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  181. package/src/ssr/platform-messages.ts +18 -0
  182. package/src/ssr/shell-data.tsx +2 -1
  183. package/src/styles/effects.css +198 -9
  184. package/src/styles/file-chooser.css +2 -1
  185. package/src/styles/global.css +49 -12
  186. package/src/styles/panel.css +47 -4
  187. package/src/styles/resource-search.css +6 -2
  188. package/src/styles/tokens.css +17 -11
  189. package/src/styles/utilities-buttons.css +3 -2
  190. package/src/styles/utilities-data.css +2 -1
  191. package/src/styles/utilities-feedback.css +2 -2
  192. package/src/styles/utilities-layout.css +3 -13
  193. package/src/styles/utilities-markdown-editor.css +6 -6
  194. 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<Array<{ auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>>`
184
- SELECT auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR UPDATE
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 (c: Context, userId: string, options: SessionIssueOptions, attempt = 0): Promise<string> => {
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
- if (token) return token;
261
- if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1);
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 (c: Context, userId: string): Promise<string> => {
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 clientToken = await issueJwtSession(c, userId, { ttlSeconds });
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);
@@ -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,
@@ -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"],
@@ -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
+ };