@k2b/cloud 0.22.0 → 0.24.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -8,6 +8,8 @@ import type { CapabilityActionManifest, CapabilityQueryManifest } from "./capabi
8
8
  export type CapabilityContract = {
9
9
  kind: "query" | "action";
10
10
  idempotency?: "required";
11
+ /** The binary stream direction the operation must declare. Without it, streaming operations do not match. */
12
+ stream?: "read" | "write";
11
13
  input: z.ZodType;
12
14
  data: z.ZodType;
13
15
  };
@@ -102,6 +104,20 @@ const patternMatches = (pattern: string, value: string): boolean => {
102
104
  }
103
105
  };
104
106
 
107
+ type Bound = { value: number; exclusive: boolean };
108
+ const lowerBound = (schema: Json): Bound | null =>
109
+ typeof schema.exclusiveMinimum === "number"
110
+ ? { value: schema.exclusiveMinimum, exclusive: true }
111
+ : typeof schema.minimum === "number"
112
+ ? { value: schema.minimum, exclusive: false }
113
+ : null;
114
+ const upperBound = (schema: Json): Bound | null =>
115
+ typeof schema.exclusiveMaximum === "number"
116
+ ? { value: schema.exclusiveMaximum, exclusive: true }
117
+ : typeof schema.maximum === "number"
118
+ ? { value: schema.maximum, exclusive: false }
119
+ : null;
120
+
105
121
  /** Checks one literal value against a provider or contract schema. Unknown keywords never pass. */
106
122
  const valueIssue = (value: unknown, sup: Side, depth: number): string | null => {
107
123
  const resolved = deref(sup, depth);
@@ -126,22 +142,15 @@ const valueIssue = (value: unknown, sup: Side, depth: number): string | null =>
126
142
  if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return "value is too long";
127
143
  if (typeof schema.pattern === "string" && !patternMatches(schema.pattern, value)) return "value does not match the pattern";
128
144
  }
145
+ if (typeof value === "number") {
146
+ const lower = lowerBound(schema);
147
+ const upper = upperBound(schema);
148
+ if (lower && (lower.exclusive ? value <= lower.value : value < lower.value)) return `value ${value} is out of range`;
149
+ if (upper && (upper.exclusive ? value >= upper.value : value > upper.value)) return `value ${value} is out of range`;
150
+ }
129
151
  return null;
130
152
  };
131
153
 
132
- type Bound = { value: number; exclusive: boolean };
133
- const lowerBound = (schema: Json): Bound | null =>
134
- typeof schema.exclusiveMinimum === "number"
135
- ? { value: schema.exclusiveMinimum, exclusive: true }
136
- : typeof schema.minimum === "number"
137
- ? { value: schema.minimum, exclusive: false }
138
- : null;
139
- const upperBound = (schema: Json): Bound | null =>
140
- typeof schema.exclusiveMaximum === "number"
141
- ? { value: schema.exclusiveMaximum, exclusive: true }
142
- : typeof schema.maximum === "number"
143
- ? { value: schema.maximum, exclusive: false }
144
- : null;
145
154
  const withinLower = (sub: Bound | null, sup: Bound | null): boolean =>
146
155
  !sup || (sub !== null && (sub.value > sup.value || (sub.value === sup.value && (sub.exclusive || !sup.exclusive))));
147
156
  const withinUpper = (sub: Bound | null, sup: Bound | null): boolean =>
@@ -356,7 +365,12 @@ export const capabilityContractIssues = (
356
365
  return [{ code: "kind", path: "$", message: `Expected ${contract.kind === "query" ? "a Query" : "an Action"}` }];
357
366
  }
358
367
  const issues: CapabilityContractIssue[] = [];
359
- if (candidate.operation.stream) issues.push({ code: "stream", path: "$", message: "Streaming capabilities are not supported" });
368
+ const direction = candidate.operation.stream?.direction;
369
+ if (contract.stream && direction !== contract.stream) {
370
+ issues.push({ code: "stream", path: "$", message: `Expected a ${contract.stream} stream` });
371
+ } else if (!contract.stream && direction) {
372
+ issues.push({ code: "stream", path: "$", message: "Streaming capabilities are not supported" });
373
+ }
360
374
  if (contract.idempotency === "required" && candidate.kind === "action" && candidate.operation.idempotency !== "required") {
361
375
  issues.push({ code: "idempotency", path: "$", message: "The Action must require an idempotency key" });
362
376
  }
@@ -0,0 +1,158 @@
1
+ import { z } from "zod";
2
+ import type { CapabilityManifest } from "./capabilities";
3
+ import { type CapabilityContract, type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
4
+
5
+ /**
6
+ * Provider-neutral file-provider contract.
7
+ *
8
+ * An application offers files to other applications by declaring
9
+ * `fileProvider: { list, read, save? }` in `defineCapabilities`. Each entry
10
+ * names one of its own Queries or Actions; the schemas below are the minimum
11
+ * both sides share. Identifiers and cursors are opaque provider strings, and
12
+ * the provider authorizes every call with the caller's own access.
13
+ */
14
+
15
+ // Bounds follow the shared provider page: 100 entries, 3 tags of 40 characters.
16
+ const ProviderIdSchema = z.string().min(1).max(2048);
17
+ const CursorSchema = z.string().min(1).max(16384);
18
+ const TimestampSchema = z.string().datetime({ offset: true });
19
+
20
+ /** One display chip. Tags describe an entry; they are not a filter or a permission. */
21
+ export const FileProviderTagSchema = z
22
+ .object({
23
+ label: z.string().min(1).max(40),
24
+ tone: z.enum(["neutral", "info", "success", "warning", "danger"]).optional(),
25
+ })
26
+ .strict();
27
+
28
+ export const FileProviderListInputSchema = z
29
+ .object({
30
+ parent: ProviderIdSchema.optional().describe("Folder ID returned by this provider; omit for the provider root."),
31
+ query: z.string().max(200).optional().describe("Optional name filter inside the folder."),
32
+ cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page; keep parent, query, and limit unchanged."),
33
+ limit: z.number().int().min(1).max(100).default(50).describe("Maximum number of entries to return."),
34
+ })
35
+ .strict();
36
+
37
+ const entryFacts = {
38
+ id: ProviderIdSchema,
39
+ name: z.string().min(1).max(255),
40
+ updatedAt: TimestampSchema.optional(),
41
+ icon: z.string().min(1).max(120).optional(),
42
+ tags: z.array(FileProviderTagSchema).max(3).optional(),
43
+ };
44
+
45
+ export const FileProviderEntrySchema = z.discriminatedUnion("kind", [
46
+ z.object({ kind: z.literal("folder"), ...entryFacts }).loose(),
47
+ z
48
+ .object({
49
+ kind: z.literal("file"),
50
+ ...entryFacts,
51
+ size: z.number().int().min(0),
52
+ mediaType: z.string().min(1).max(255).optional(),
53
+ })
54
+ .loose(),
55
+ ]);
56
+
57
+ /** One folder page. Folders may be virtual; pages may be short or empty and still continue while `next` is set. */
58
+ export const FileProviderListDataSchema = z
59
+ .object({
60
+ writable: z.boolean(),
61
+ items: z.array(FileProviderEntrySchema).max(100),
62
+ next: CursorSchema.nullable(),
63
+ })
64
+ .loose();
65
+
66
+ export const FileProviderReadInputSchema = z
67
+ .object({ id: ProviderIdSchema.describe("File ID returned by this provider's list.") })
68
+ .strict();
69
+ export const FileProviderReadDataSchema = z.object({}).loose();
70
+
71
+ /** A single file name: no path separators, control characters, `.` or `..`. */
72
+ const FileNameSchema = z
73
+ .string()
74
+ .min(1)
75
+ .max(255)
76
+ .regex(/^(?!\.{1,2}$)[^/\\\u0000-\u001f\u007f]+$/);
77
+
78
+ export const FileProviderSaveInputSchema = z
79
+ .object({
80
+ parent: ProviderIdSchema.describe("Writable folder ID returned by this provider's list."),
81
+ name: FileNameSchema.describe("Name of the new file."),
82
+ mediaType: z
83
+ .string()
84
+ .min(1)
85
+ .max(255)
86
+ .regex(/^[\x20-\x7e]+$/)
87
+ .describe("Media type of the content."),
88
+ size: z.number().int().min(0).describe("Exact byte count of the content."),
89
+ })
90
+ .strict();
91
+
92
+ /** `file` is present once the write stream completed; the call that opens the stream has no file yet. */
93
+ export const FileProviderSaveDataSchema = z
94
+ .object({
95
+ file: z
96
+ .object({ id: ProviderIdSchema, name: z.string().min(1).max(255), size: z.number().int().min(0) })
97
+ .loose()
98
+ .optional(),
99
+ })
100
+ .loose();
101
+
102
+ /**
103
+ * The error code `save` answers, with status `409`, when the name already
104
+ * exists in the folder. Consumers ask for another name only on this code;
105
+ * every other failure keeps its own code, even with status `409`.
106
+ */
107
+ export const FILE_PROVIDER_NAME_CONFLICT = "FILE_NAME_CONFLICT";
108
+
109
+ /**
110
+ * The three file-provider functions. `read` streams the file's bytes, `save`
111
+ * is an idempotent Action with a write stream that only creates files: an
112
+ * existing name fails with `FILE_PROVIDER_NAME_CONFLICT`.
113
+ */
114
+ export const fileProvider = {
115
+ list: { kind: "query", input: FileProviderListInputSchema, data: FileProviderListDataSchema },
116
+ read: { kind: "query", input: FileProviderReadInputSchema, data: FileProviderReadDataSchema, stream: "read" },
117
+ save: {
118
+ kind: "action",
119
+ idempotency: "required",
120
+ input: FileProviderSaveInputSchema,
121
+ data: FileProviderSaveDataSchema,
122
+ stream: "write",
123
+ },
124
+ } as const satisfies Record<string, CapabilityContract>;
125
+
126
+ export type FileProviderFunction = keyof typeof fileProvider;
127
+ export const FILE_PROVIDER_FUNCTIONS = ["list", "read", "save"] as const satisfies readonly FileProviderFunction[];
128
+
129
+ export type FileProviderTag = z.output<typeof FileProviderTagSchema>;
130
+ export type FileProviderEntry = z.output<typeof FileProviderEntrySchema>;
131
+ export type FileProviderListData = z.output<typeof FileProviderListDataSchema>;
132
+ export type FileProviderSaveData = z.output<typeof FileProviderSaveDataSchema>;
133
+
134
+ export type FileProviderIssue = CapabilityContractIssue & { function: FileProviderFunction; localId: string };
135
+
136
+ /**
137
+ * Explains why a manifest's `fileProvider` declaration cannot serve the
138
+ * contract. Apps run it at start and fail; Core runs it when it reads a live
139
+ * manifest and ignores an invalid declaration. An empty list means valid or
140
+ * not declared.
141
+ */
142
+ export const fileProviderIssues = (manifest: CapabilityManifest): FileProviderIssue[] => {
143
+ const declaration = manifest.fileProvider;
144
+ if (!declaration) return [];
145
+ return FILE_PROVIDER_FUNCTIONS.flatMap((name) => {
146
+ const localId = declaration[name];
147
+ if (localId === undefined) return [];
148
+ const contract = fileProvider[name];
149
+ const query = manifest.queries.find((operation) => operation.localId === localId);
150
+ const action = manifest.actions.find((operation) => operation.localId === localId);
151
+ const issues = query
152
+ ? capabilityContractIssues(contract, { kind: "query", operation: query })
153
+ : action
154
+ ? capabilityContractIssues(contract, { kind: "action", operation: action })
155
+ : [{ code: "kind" as const, path: "$", message: `No Query or Action named ${localId}` }];
156
+ return issues.map((issue) => ({ ...issue, function: name, localId }));
157
+ });
158
+ };
@@ -6,9 +6,11 @@ export * from "./capabilities";
6
6
  export * from "./capability-compatibility";
7
7
  export * from "./commands";
8
8
  export * from "./contact-directory";
9
+ export * from "./file-provider";
9
10
  export * from "./notification-types";
10
11
  export * from "./posix";
11
12
  export * from "./profile";
13
+ export * from "./pwa";
12
14
  // Core app bar administration and shared shortcut values.
13
15
  export { RailAccessSchema, type RailAdminEntry, RailAdminInputSchema, RailAdminSchema, type RailAdminState } from "./rail-admin";
14
16
  export type { RailShortcut } from "./rail-preferences";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Paths of the installable mobile app, without schemas, so browser code that runs on every page can test for an
3
+ * app page without loading the contract's validators.
4
+ */
5
+ export const PWA_SCOPE = "/pwa/";
6
+ export const PWA_AUTH_PATH = "/pwa/_auth";
@@ -0,0 +1,156 @@
1
+ import { z } from "zod";
2
+ import { PWA_AUTH_PATH, PWA_SCOPE } from "./pwa-paths";
3
+
4
+ export { PWA_AUTH_PATH, PWA_SCOPE };
5
+
6
+ /**
7
+ * The installable mobile app (preview). One shell application `pwa` owns the
8
+ * manifest scope `/pwa/`; Core owns the phone-side identity endpoints below
9
+ * `/pwa/_auth` and the web-side pairing API.
10
+ */
11
+ export const PWA_SHELL_APP_ID = "pwa";
12
+ export const PWA_API_PATH = "/api/auth/pwa/v1";
13
+ export const PWA_MANIFEST_PATH = "/pwa/manifest.webmanifest";
14
+ export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
15
+ /** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
16
+ export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
17
+ /** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
18
+ export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
19
+ /**
20
+ * The page canvas of the app (`--k2b-surface-canvas` of `@k2b/ui`) in light and dark. App documents paint it
21
+ * before any stylesheet loads and give it to the status bar.
22
+ */
23
+ export const PWA_CANVAS_COLORS = { light: "#fafafa", dark: "#090d12" } as const;
24
+
25
+ /** Cookie names. Only `pwa_session` reaches every application; the others use Core-only paths. */
26
+ export const PWA_COOKIES = {
27
+ session: "pwa_session",
28
+ device: "pwa_device",
29
+ pairing: "pwa_pairing",
30
+ } as const;
31
+
32
+ export const PWA_LIMITS = {
33
+ /** Same as the Cloud Login API. */
34
+ bodyBytes: 8192,
35
+ /** A visible link can be claimed for five minutes. */
36
+ linkClaimSeconds: 300,
37
+ /** Claim, typing the code and completion. */
38
+ pairingSeconds: 600,
39
+ /** Starting a pairing needs a web sign-in from the last ten minutes. */
40
+ recentSessionSeconds: 600,
41
+ /** Wrong codes before the pairing is cancelled. */
42
+ confirmAttempts: 3,
43
+ devicesPerAccount: 20,
44
+ pendingPerAccount: 5,
45
+ /** A paired phone ends after this many days without use. */
46
+ idleDays: 150,
47
+ /** Lifetime of one app session. */
48
+ sessionSeconds: 86_400,
49
+ /** Renew when the app session has less than this left. */
50
+ renewWithinSeconds: 43_200,
51
+ /** Parallel renewals within this window do not re-issue. */
52
+ rotationGraceSeconds: 60,
53
+ pollSeconds: 5,
54
+ nameMaxLength: 80,
55
+ } as const;
56
+
57
+ /** True while a live app with id "pwa" owns "/pwa". Pure; Core uses it for gating. */
58
+ export const isPwaShellAvailable = (apps: readonly { id: string; routes: readonly string[] }[]): boolean =>
59
+ apps.some((app) => app.id === PWA_SHELL_APP_ID && app.routes.includes("/pwa"));
60
+
61
+ const SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
62
+
63
+ /** The link the web shows as QR code and copy text. The secret lives only in the fragment. */
64
+ export const pairingLink = (origin: string, secret: string): string => {
65
+ if (!SECRET_PATTERN.test(secret)) throw new Error("Invalid pairing secret");
66
+ return `${new URL(origin).origin}${PWA_SCOPE}#pair=${secret}`;
67
+ };
68
+
69
+ export type PairingLinkResult = { ok: true; secret: string } | { ok: false; reason: "invalid" | "other-cloud" | "cloud-login" };
70
+
71
+ /** Strict parser for scanned or pasted text. Never fetches anything. */
72
+ export const parsePairingLink = (text: string, origin: string): PairingLinkResult => {
73
+ if (text.length > PWA_LIMITS.bodyBytes) return { ok: false, reason: "invalid" };
74
+ let url: URL;
75
+ try {
76
+ url = new URL(text.trim());
77
+ } catch {
78
+ return { ok: false, reason: "invalid" };
79
+ }
80
+ if (url.hash.startsWith("#pairing=")) return { ok: false, reason: "cloud-login" };
81
+ if (!url.hash.startsWith("#pair=") || url.pathname !== PWA_SCOPE || url.search || url.username || url.password) {
82
+ return { ok: false, reason: "invalid" };
83
+ }
84
+ if (url.origin !== new URL(origin).origin) return { ok: false, reason: "other-cloud" };
85
+ const secret = url.hash.slice("#pair=".length);
86
+ return SECRET_PATTERN.test(secret) ? { ok: true, secret } : { ok: false, reason: "invalid" };
87
+ };
88
+
89
+ const Id = z.string().uuid();
90
+ const DateTime = z.string().datetime();
91
+ const Code = z.string().regex(/^\d{6}$/);
92
+
93
+ export const PwaPlatformSchema = z.enum(["ios", "android", "other"]);
94
+ export type PwaPlatform = z.infer<typeof PwaPlatformSchema>;
95
+ export const PwaPairingStateSchema = z.enum(["pending", "claimed", "confirmed", "completed", "cancelled"]);
96
+ export type PwaPairingState = z.infer<typeof PwaPairingStateSchema>;
97
+ /** States of `/pwa/` the launch bounce leads to when it cannot renew. */
98
+ export const PwaLaunchStateSchema = z.enum(["new", "ended", "blocked", "unavailable"]);
99
+ export type PwaLaunchState = z.infer<typeof PwaLaunchStateSchema>;
100
+
101
+ export const PwaErrorCodeSchema = z.enum([
102
+ "UNAVAILABLE",
103
+ "REAUTHENTICATE",
104
+ "FORBIDDEN",
105
+ "ACCOUNT_BLOCKED",
106
+ "INVALID_REQUEST",
107
+ "NOT_FOUND",
108
+ "EXPIRED",
109
+ "ALREADY_USED",
110
+ "WRONG_CODE",
111
+ "CONFLICT",
112
+ "ALREADY_PAIRED",
113
+ "ACCOUNT_MISMATCH",
114
+ "LIMIT_REACHED",
115
+ "UNPAIRED",
116
+ ]);
117
+ export type PwaErrorCode = z.infer<typeof PwaErrorCodeSchema>;
118
+ export const PwaErrorSchema = z.object({ code: PwaErrorCodeSchema, message: z.string(), attemptsLeft: z.number().int().optional() });
119
+
120
+ // Web side (`/api/auth/pwa/v1`).
121
+ export const PwaPairingStartResultSchema = z
122
+ .object({ id: Id, secret: z.string().regex(SECRET_PATTERN), claimUntil: DateTime, expiresAt: DateTime })
123
+ .strict();
124
+ export const PwaPairingStatusSchema = z
125
+ .object({
126
+ state: PwaPairingStateSchema,
127
+ claimUntil: DateTime,
128
+ expiresAt: DateTime,
129
+ device: z.object({ name: z.string(), platform: PwaPlatformSchema }).strict().nullable(),
130
+ attemptsLeft: z.number().int().min(0),
131
+ })
132
+ .strict();
133
+ export const PwaPairingConfirmSchema = z.object({ code: Code }).strict();
134
+ export const PwaDeviceViewSchema = z
135
+ .object({
136
+ id: Id,
137
+ name: z.string(),
138
+ platform: PwaPlatformSchema,
139
+ createdAt: DateTime,
140
+ lastUsedAt: DateTime,
141
+ current: z.boolean(),
142
+ })
143
+ .strict();
144
+ export type PwaDeviceView = z.infer<typeof PwaDeviceViewSchema>;
145
+ export const PwaDeviceListSchema = z.object({ items: z.array(PwaDeviceViewSchema) }).strict();
146
+
147
+ // Phone side (`/pwa/_auth`).
148
+ export const PwaClaimSchema = z.object({ secret: z.string().regex(SECRET_PATTERN), platform: PwaPlatformSchema }).strict();
149
+ const Account = z.object({ name: z.string() }).strict();
150
+ export const PwaClaimResultSchema = z.object({ code: Code, account: Account, expiresAt: DateTime }).strict();
151
+ export const PwaCompleteResultSchema = z.discriminatedUnion("state", [
152
+ z.object({ state: z.literal("paired") }).strict(),
153
+ z.object({ state: z.literal("waiting"), code: Code, account: Account, expiresAt: DateTime }).strict(),
154
+ ]);
155
+ export const PwaRenewResultSchema = z.object({ renewed: z.boolean(), otherAccount: Account.optional() }).strict();
156
+ export const PwaRenameSchema = z.object({ name: z.string().trim().min(1).max(PWA_LIMITS.nameMaxLength) }).strict();
@@ -18,6 +18,11 @@ export type AppRegistryNav = {
18
18
  adminHref?: string;
19
19
  };
20
20
 
21
+ export type AppRegistryPwaPart = {
22
+ href: string;
23
+ requiresRoles?: Role[];
24
+ };
25
+
21
26
  export type AppRegistryCapabilitySummary = {
22
27
  protocolVersion: number;
23
28
  manifestHash: string;
@@ -80,6 +85,8 @@ export type AppRegistryEntry = {
80
85
  help?: AppRegistryHelpSummary;
81
86
  legalLinks?: AppRegistryLegalLink[];
82
87
  searchLinks?: readonly AppSearchLink[];
88
+ /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
89
+ pwa?: AppRegistryPwaPart;
83
90
  widgets?: AppRegistryWidget[];
84
91
  /** Setting keys declared by this app. Used by admin tooling to avoid treating live app-owned settings as legacy. */
85
92
  settingKeys?: readonly string[];
@@ -414,7 +414,13 @@ export type InvocationProvenance = {
414
414
  workloadId?: string;
415
415
  };
416
416
 
417
- export type UserRequestActor = { kind: "user"; user: User; delegation?: InvocationProvenance };
417
+ export type UserRequestActor = {
418
+ kind: "user";
419
+ user: User;
420
+ delegation?: InvocationProvenance;
421
+ /** `"app"` when the person acts through the mobile app's session; see the server's `UserRequestActor`. */
422
+ sessionKind?: "app";
423
+ };
418
424
 
419
425
  export type ServiceAccountRequestActor = {
420
426
  kind: "service_account";
@@ -0,0 +1,2 @@
1
+ export type { LiveChannel, LiveViewer } from "./live";
2
+ export { defineLive } from "./live";