@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.
- package/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +830 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +70 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- 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
|
-
|
|
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
|
+
};
|
package/src/contracts/index.ts
CHANGED
|
@@ -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,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[];
|
package/src/contracts/shared.ts
CHANGED
|
@@ -414,7 +414,13 @@ export type InvocationProvenance = {
|
|
|
414
414
|
workloadId?: string;
|
|
415
415
|
};
|
|
416
416
|
|
|
417
|
-
export type UserRequestActor = {
|
|
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";
|