@uniqbit/mate-core 0.17.0-canary.10 → 0.17.0-canary.11
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 +6 -1
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -15
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +203 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +1 -1
- package/src/cli/commands/studio/server.ts +281 -18
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +1 -6
- package/src/cli/commands/studio/views/document.tsx +12 -2
- package/src/cli/commands/studio/views/model.ts +10 -0
- package/src/cli/commands/studio/views/styles.ts +11 -0
- package/src/cli/commands/studio/views/terminal.tsx +232 -0
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/usage.ts +4 -2
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode.ts +0 -2
- package/src/tools/setup/runtime-documents.ts +1 -7
- package/src/tools/setup.ts +0 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-core",
|
|
3
|
-
"version": "0.17.0-canary.
|
|
3
|
+
"version": "0.17.0-canary.11",
|
|
4
4
|
"description": "Core framework and plugin APIs for Mate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
"@opentui/keymap": "^0.4.5",
|
|
37
37
|
"@opentui/solid": "^0.4.5",
|
|
38
38
|
"@tanstack/highlight": "0.0.10",
|
|
39
|
+
"@xterm/addon-clipboard": "^0.2.0",
|
|
40
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
41
|
+
"@xterm/addon-unicode11": "^0.9.0",
|
|
42
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
43
|
+
"@xterm/xterm": "^6.0.0",
|
|
39
44
|
"hono": "^4.13.5",
|
|
40
45
|
"ink": "^7.1.0",
|
|
41
46
|
"react": "^19.2.7",
|
|
@@ -1,10 +1 @@
|
|
|
1
1
|
export { runArtifactPublishCommand } from "./command";
|
|
2
|
-
export type { PublishCommandDeps } from "./command";
|
|
3
|
-
export type { FinishResult, FinishStep, FinishStatus } from "./engine";
|
|
4
|
-
export type {
|
|
5
|
-
ArtifactFinisher,
|
|
6
|
-
FinishContext,
|
|
7
|
-
FinisherFactory,
|
|
8
|
-
Produced,
|
|
9
|
-
ResolveResult,
|
|
10
|
-
} from "./finisher";
|
|
@@ -269,7 +269,7 @@ async function resolved(
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
/** Tag namespace segregating spec publications from dated change anchors. */
|
|
272
|
-
|
|
272
|
+
const SPEC_TAG_NAMESPACE = "openspec/specs";
|
|
273
273
|
/** Spec ids named individually before the remainder collapses into a count. */
|
|
274
274
|
const SPEC_LABEL_LIMIT = 3;
|
|
275
275
|
|
|
@@ -297,7 +297,7 @@ function specLabel(paths: string[]): string {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/** Commit subject for a spec publication; it belongs to no change, so it names specs, never an anchor. */
|
|
300
|
-
|
|
300
|
+
function specCommitSubject(paths: string[]): string {
|
|
301
301
|
const ids = specIds(paths);
|
|
302
302
|
const named = ids.slice(0, SPEC_LABEL_LIMIT);
|
|
303
303
|
const remaining = ids.length - named.length;
|
|
@@ -5,7 +5,7 @@ import { openspecFinisher } from "./openspec";
|
|
|
5
5
|
* Artifact-kind → finisher factory. openspec is the only kind for now; a future ADR
|
|
6
6
|
* finisher registers here and is selected via `--type`, with no engine changes.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
const FINISHERS: Record<string, FinisherFactory> = {
|
|
9
9
|
openspec: openspecFinisher,
|
|
10
10
|
};
|
|
11
11
|
|
|
@@ -52,7 +52,7 @@ export interface ArchiveEntry {
|
|
|
52
52
|
const ARCHIVE_ANCHOR_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
|
|
53
53
|
const ARCHIVE_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
const ARCHIVE_RELATIVE_DIR = "openspec/changes/archive";
|
|
56
56
|
export const SPECS_RELATIVE_DIR = "openspec/specs";
|
|
57
57
|
|
|
58
58
|
/** The tag `mate artifact publish` creates, never a recomputed date. */
|
|
@@ -1,17 +1,2 @@
|
|
|
1
1
|
export { runArtifactPendingCommand } from "./command";
|
|
2
2
|
export type { PendingCommandDeps, PendingResult } from "./command";
|
|
3
|
-
export {
|
|
4
|
-
ARCHIVE_RELATIVE_DIR,
|
|
5
|
-
SPECS_RELATIVE_DIR,
|
|
6
|
-
discoverArchives,
|
|
7
|
-
finishMarker,
|
|
8
|
-
pendingArchives,
|
|
9
|
-
unattributedSpecs,
|
|
10
|
-
} from "./discovery";
|
|
11
|
-
export type {
|
|
12
|
-
ArchiveEntry,
|
|
13
|
-
CommitState,
|
|
14
|
-
SpecAttribution,
|
|
15
|
-
UnattributedSpec,
|
|
16
|
-
UncommittedSpecChange,
|
|
17
|
-
} from "./discovery";
|
|
@@ -29,7 +29,7 @@ const GRAPHIFY_PATH_COMMANDS = new Set([
|
|
|
29
29
|
|
|
30
30
|
const GRAPHIFY_GRAPH_COMMANDS = new Set(["query", "path", "explain", "affected", "tree"]);
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
function deriveGraphifyPaths(companionPath: string, repositoryId: string): GraphifyPaths {
|
|
33
33
|
const graphifyOut = path.join(
|
|
34
34
|
companionPath,
|
|
35
35
|
GRAPHIFY_STORE_SEGMENT,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
updateHubPlugins,
|
|
9
9
|
} from "../../../lib/orchestrator/companion-hub";
|
|
10
10
|
import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
|
|
11
|
+
import { writeJsonStdout } from "../../write-json-stdout";
|
|
11
12
|
|
|
12
13
|
function positionalArgs(argv: string[]): string[] {
|
|
13
14
|
const values: string[] = [];
|
|
@@ -71,7 +72,7 @@ async function runHubSync(argv: string[]): Promise<void> {
|
|
|
71
72
|
const results = await syncHub(process.cwd());
|
|
72
73
|
const plugins = await updateHubPlugins(process.cwd());
|
|
73
74
|
if (argv.includes("--json")) {
|
|
74
|
-
|
|
75
|
+
await writeJsonStdout({ companions: results, plugins }, { space: 0 });
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
for (const result of results) console.log(`${result.id}: ${result.status} (${result.message})`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CompanionStore } from "../../../lib/orchestrator/companion-store";
|
|
2
2
|
import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
|
|
3
3
|
import { renderRepoListTable } from "../../repo-list-table";
|
|
4
|
+
import { writeJsonStdout } from "../../write-json-stdout";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @command mate companion list
|
|
@@ -23,7 +24,7 @@ export async function runCompanionListCommand(argv: string[] = []): Promise<void
|
|
|
23
24
|
const forceJson = argv.includes("--json");
|
|
24
25
|
|
|
25
26
|
if (forceJson || !process.stdout.isTTY) {
|
|
26
|
-
|
|
27
|
+
await writeJsonStdout({ repositories });
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
4
4
|
import { preparePrebuiltWorkspace } from "../../../lib/prebuilt-workspace-prepare";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const companionPrepareCommandDeps = {
|
|
7
7
|
preparePrebuiltWorkspace,
|
|
8
8
|
};
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ interface ParsedPrepareArgs {
|
|
|
13
13
|
error?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
function parsePrepareArgs(argv: string[]): ParsedPrepareArgs {
|
|
17
17
|
let bundle: string | undefined;
|
|
18
18
|
const positional: string[] = [];
|
|
19
19
|
|
|
@@ -10,6 +10,7 @@ import { resolveRootContext, type RootContext } from "../../lib/orchestrator/roo
|
|
|
10
10
|
import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
|
|
11
11
|
import { getRequiredPluginDrift } from "../../tools/setup/policy";
|
|
12
12
|
import { resolveCommandOnPath } from "../../tools/setup/utils";
|
|
13
|
+
import { writeJsonStdout } from "../write-json-stdout";
|
|
13
14
|
import { printSection, renderKeyValueTable, renderTable } from "./status";
|
|
14
15
|
|
|
15
16
|
interface DoctorDeps {
|
|
@@ -354,7 +355,7 @@ function renderHumanReport(report: DoctorReport): void {
|
|
|
354
355
|
export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
|
|
355
356
|
const report = await collectDoctorReport(deps);
|
|
356
357
|
if (argv.includes("--json")) {
|
|
357
|
-
|
|
358
|
+
await writeJsonStdout(report);
|
|
358
359
|
return;
|
|
359
360
|
}
|
|
360
361
|
renderHumanReport(report);
|
|
@@ -10,6 +10,7 @@ import { createStartupProgress } from "../../../lib/components/startup-progress"
|
|
|
10
10
|
import { ensureUnambiguousCompanion, launchAmbiguityDeps } from "../shared/companion-selection";
|
|
11
11
|
import { runIndexCapCommand } from "../cap/index-cmd";
|
|
12
12
|
import type { LaunchTarget } from "../../launch-selector";
|
|
13
|
+
import { writeJsonStdout } from "../../write-json-stdout";
|
|
13
14
|
|
|
14
15
|
const STEP_LABELS = {
|
|
15
16
|
sync: "Syncing mate",
|
|
@@ -127,7 +128,7 @@ export async function runLaunchToolCommand(
|
|
|
127
128
|
* The printed result stays useful, but the status is what a supervisor
|
|
128
129
|
* reads: a failed agent must fail the launch.
|
|
129
130
|
*/
|
|
130
|
-
|
|
131
|
+
await writeJsonStdout(result);
|
|
131
132
|
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
132
133
|
} catch (error) {
|
|
133
134
|
progress?.failCurrent();
|
|
@@ -4,10 +4,7 @@ import githubLight from "@tanstack/highlight/themes/github-light";
|
|
|
4
4
|
|
|
5
5
|
/** Sole boundary against `@tanstack/highlight`, so an upstream API break stays a single-file fix. */
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
export const PLAIN_LANGUAGE = "plaintext";
|
|
9
|
-
|
|
10
|
-
export function resolveHighlightLanguage(candidate: string | undefined): string {
|
|
7
|
+
function resolveHighlightLanguage(candidate: string | undefined): string {
|
|
11
8
|
return normalizeLanguage(candidate);
|
|
12
9
|
}
|
|
13
10
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Shortest operator-supplied token accepted; a generated one is 43 characters. */
|
|
4
|
+
export const MIN_TOKEN_LENGTH = 32;
|
|
5
|
+
|
|
6
|
+
const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
|
|
7
|
+
|
|
8
|
+
export type StudioInvocation = "interactive" | "serve";
|
|
9
|
+
|
|
10
|
+
export interface StudioAccessOptions {
|
|
11
|
+
invocation: StudioInvocation;
|
|
12
|
+
terminal: boolean;
|
|
13
|
+
/** Bound interface as passed to the server. */
|
|
14
|
+
hostname: string;
|
|
15
|
+
/** Read at request time: the port is known only once the server has bound. */
|
|
16
|
+
port: () => number;
|
|
17
|
+
allowedHosts?: readonly string[];
|
|
18
|
+
publicOrigin?: string | null;
|
|
19
|
+
token?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StudioRequirement = "none" | "token";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One server's Host, Origin, and token policy. `null` token means the server
|
|
26
|
+
* is unguarded and issues none.
|
|
27
|
+
*/
|
|
28
|
+
export interface StudioAccess {
|
|
29
|
+
invocation: StudioInvocation;
|
|
30
|
+
guarded: boolean;
|
|
31
|
+
token: string | null;
|
|
32
|
+
cookieName(): string;
|
|
33
|
+
/** Accepted origin for this request, or `null` when its Host is not one Studio answers to. */
|
|
34
|
+
acceptedOrigin(request: Request): string | null;
|
|
35
|
+
originMatches(request: Request, acceptedOrigin: string): boolean;
|
|
36
|
+
hasToken(request: Request): boolean;
|
|
37
|
+
/** Whether a request of this kind needs the token under this invocation. */
|
|
38
|
+
requires(kind: "read" | "save" | "terminal"): boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function generateStudioToken(): string {
|
|
42
|
+
return randomBytes(32).toString("base64url");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateStudioToken(value: string): string | null {
|
|
46
|
+
if (value.trim().length < MIN_TOKEN_LENGTH) {
|
|
47
|
+
return `the supplied token must be at least ${MIN_TOKEN_LENGTH} characters`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `null` for anything other than an exact `http(s)://host[:port]` origin. */
|
|
53
|
+
export function normalizeOrigin(value: string): string | null {
|
|
54
|
+
let parsed: URL;
|
|
55
|
+
try {
|
|
56
|
+
parsed = new URL(value);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
61
|
+
if (parsed.username || parsed.password) return null;
|
|
62
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash || value.endsWith("/")) return null;
|
|
63
|
+
return parsed.origin;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lower-cased `host[:port]` with the HTTP default port dropped, or `null` when unparsable. */
|
|
67
|
+
export function normalizeAuthority(value: string): string | null {
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
if (!trimmed || /[\s/?#@\\]/.test(trimmed)) return null;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = new URL(`http://${trimmed}`);
|
|
72
|
+
return parsed.host;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function bracketed(hostname: string): string {
|
|
79
|
+
return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function digest(value: string): Buffer {
|
|
83
|
+
return createHash("sha256").update(value).digest();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Hashing first keeps the comparison constant-time for inputs of any length. */
|
|
87
|
+
export function tokensEqual(candidate: string, expected: string): boolean {
|
|
88
|
+
return timingSafeEqual(digest(candidate), digest(expected));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readCookie(request: Request, name: string): string | null {
|
|
92
|
+
const header = request.headers.get("cookie");
|
|
93
|
+
if (!header) return null;
|
|
94
|
+
for (const part of header.split(";")) {
|
|
95
|
+
const index = part.indexOf("=");
|
|
96
|
+
if (index < 0) continue;
|
|
97
|
+
if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createStudioAccess(options: StudioAccessOptions): StudioAccess {
|
|
103
|
+
const guarded = options.invocation === "serve" || options.terminal;
|
|
104
|
+
const token = guarded ? (options.token ?? generateStudioToken()) : null;
|
|
105
|
+
const publicOrigin = options.publicOrigin ? normalizeOrigin(options.publicOrigin) : null;
|
|
106
|
+
const publicAuthority = publicOrigin ? new URL(publicOrigin).host : null;
|
|
107
|
+
const configured = new Set<string>();
|
|
108
|
+
for (const host of options.allowedHosts ?? []) {
|
|
109
|
+
const authority = normalizeAuthority(host);
|
|
110
|
+
if (authority) configured.add(authority);
|
|
111
|
+
}
|
|
112
|
+
if (publicAuthority) configured.add(publicAuthority);
|
|
113
|
+
|
|
114
|
+
const accepted = (): Set<string> => {
|
|
115
|
+
const port = options.port();
|
|
116
|
+
const hosts = new Set(configured);
|
|
117
|
+
for (const hostname of [...LOOPBACK_HOSTNAMES, bracketed(options.hostname)]) {
|
|
118
|
+
const authority = normalizeAuthority(`${hostname}:${port}`);
|
|
119
|
+
if (authority) hosts.add(authority);
|
|
120
|
+
}
|
|
121
|
+
return hosts;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
invocation: options.invocation,
|
|
126
|
+
guarded,
|
|
127
|
+
token,
|
|
128
|
+
cookieName: () => `mate_studio_${options.port()}`,
|
|
129
|
+
acceptedOrigin(request) {
|
|
130
|
+
const raw = request.headers.get("host") ?? new URL(request.url).host;
|
|
131
|
+
const authority = normalizeAuthority(raw);
|
|
132
|
+
if (!authority || !accepted().has(authority)) return null;
|
|
133
|
+
if (publicOrigin && authority === publicAuthority) return publicOrigin;
|
|
134
|
+
return `http://${authority}`;
|
|
135
|
+
},
|
|
136
|
+
originMatches(request, acceptedOrigin) {
|
|
137
|
+
const origin = request.headers.get("origin");
|
|
138
|
+
if (!origin || origin === "null") return false;
|
|
139
|
+
return normalizeOrigin(origin) === acceptedOrigin;
|
|
140
|
+
},
|
|
141
|
+
hasToken(request) {
|
|
142
|
+
if (!token) return false;
|
|
143
|
+
const value = readCookie(request, `mate_studio_${options.port()}`);
|
|
144
|
+
return value !== null && tokensEqual(value, token);
|
|
145
|
+
},
|
|
146
|
+
requires(kind) {
|
|
147
|
+
if (!guarded) return false;
|
|
148
|
+
if (options.invocation === "serve") return true;
|
|
149
|
+
return kind !== "read";
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const EXCHANGE_HEADERS = {
|
|
155
|
+
"cache-control": "no-store",
|
|
156
|
+
"referrer-policy": "no-referrer",
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Trades a `?token=` navigation for the cookie and a relative redirect, so the
|
|
161
|
+
* browser never leaves the Host it asked for. `null` when the request carries
|
|
162
|
+
* no token parameter.
|
|
163
|
+
*/
|
|
164
|
+
export function exchangeStudioToken(
|
|
165
|
+
request: Request,
|
|
166
|
+
access: StudioAccess,
|
|
167
|
+
acceptedOrigin: string,
|
|
168
|
+
): Response | null {
|
|
169
|
+
const url = new URL(request.url);
|
|
170
|
+
if (!url.searchParams.has("token")) return null;
|
|
171
|
+
if (request.method !== "GET" || !access.token) {
|
|
172
|
+
return new Response("the access token is exchanged only by opening its address", {
|
|
173
|
+
status: 400,
|
|
174
|
+
headers: EXCHANGE_HEADERS,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const candidate = url.searchParams.get("token") ?? "";
|
|
178
|
+
if (!tokensEqual(candidate, access.token)) {
|
|
179
|
+
return new Response("the access token does not match; open the address Studio printed", {
|
|
180
|
+
status: 403,
|
|
181
|
+
headers: EXCHANGE_HEADERS,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
url.searchParams.delete("token");
|
|
185
|
+
const secure = acceptedOrigin.startsWith("https:") ? "; Secure" : "";
|
|
186
|
+
return new Response(null, {
|
|
187
|
+
status: 303,
|
|
188
|
+
headers: {
|
|
189
|
+
...EXCHANGE_HEADERS,
|
|
190
|
+
location: `${url.pathname}${url.search}`,
|
|
191
|
+
"set-cookie": `${access.cookieName()}=${access.token}; Path=/; HttpOnly; SameSite=Strict${secure}`,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Address the operator opens: loopback for wildcard or loopback binds, carrying the token only when it was generated. */
|
|
197
|
+
export function studioAddress(hostname: string, port: number, token: string | null): string {
|
|
198
|
+
const wildcard = hostname === "0.0.0.0" || hostname === "::" || hostname === "[::]";
|
|
199
|
+
const loopback = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(hostname);
|
|
200
|
+
const host = wildcard || loopback ? "localhost" : bracketed(hostname);
|
|
201
|
+
const base = `http://${host}:${port}`;
|
|
202
|
+
return token ? `${base}/?token=${token}` : base;
|
|
203
|
+
}
|