@phnx-labs/agents-cli 1.22.47 → 1.22.50
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/CHANGELOG.md +503 -17
- package/README.md +76 -32
- package/dist/bootstrap.js +4 -2
- package/dist/cli/command-registry.d.ts +1 -0
- package/dist/cli/command-registry.js +2 -0
- package/dist/commands/accounts.d.ts +58 -0
- package/dist/commands/accounts.js +201 -39
- package/dist/commands/artifacts.js +7 -6
- package/dist/commands/attach.js +1 -1
- package/dist/commands/browser.js +501 -103
- package/dist/commands/config.d.ts +0 -1
- package/dist/commands/config.js +0 -20
- package/dist/commands/daemon-test-harness.d.ts +42 -0
- package/dist/commands/daemon-test-harness.js +90 -0
- package/dist/commands/daemon.d.ts +21 -0
- package/dist/commands/daemon.js +147 -12
- package/dist/commands/device-config-test-harness.d.ts +11 -0
- package/dist/commands/device-config-test-harness.js +59 -0
- package/dist/commands/exec.js +76 -16
- package/dist/commands/focus.d.ts +14 -0
- package/dist/commands/focus.js +48 -5
- package/dist/commands/go.d.ts +3 -0
- package/dist/commands/go.js +16 -3
- package/dist/commands/inspect.js +2 -2
- package/dist/commands/lease.d.ts +1 -1
- package/dist/commands/lease.js +4 -3
- package/dist/commands/mailboxes.js +4 -3
- package/dist/commands/menubar.js +11 -4
- package/dist/commands/projects.d.ts +16 -1
- package/dist/commands/projects.js +58 -64
- package/dist/commands/resume.js +1 -1
- package/dist/commands/route.js +4 -4
- package/dist/commands/routines.js +3 -2
- package/dist/commands/run-broadcast.d.ts +1 -1
- package/dist/commands/run-broadcast.js +1 -1
- package/dist/commands/sessions-picker.d.ts +22 -0
- package/dist/commands/sessions-picker.js +48 -9
- package/dist/commands/sessions-trace.js +1 -1
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/setup-secrets.js +23 -0
- package/dist/commands/share.d.ts +20 -5
- package/dist/commands/share.js +158 -53
- package/dist/commands/ssh.js +54 -7
- package/dist/commands/teams.d.ts +12 -0
- package/dist/commands/teams.js +44 -67
- package/dist/commands/traces.d.ts +2 -0
- package/dist/commands/traces.js +188 -0
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/view.js +17 -34
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/lib/account-provider-registry.d.ts +9 -0
- package/dist/lib/account-provider-registry.js +20 -0
- package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
- package/dist/lib/accounting/account-pool-collect.js +71 -0
- package/dist/lib/accounting/account-pool.d.ts +46 -0
- package/dist/lib/accounting/account-pool.js +33 -0
- package/dist/lib/accounting/capacity.d.ts +24 -0
- package/dist/lib/accounting/capacity.js +30 -0
- package/dist/lib/accounting/rotate.d.ts +11 -18
- package/dist/lib/accounting/rotate.js +6 -24
- package/dist/lib/accounting/usage.d.ts +14 -3
- package/dist/lib/accounting/usage.js +64 -18
- package/dist/lib/agent-spec/agents.js +11 -9
- package/dist/lib/browser/drivers/ssh.d.ts +16 -2
- package/dist/lib/browser/drivers/ssh.js +36 -9
- package/dist/lib/browser/ipc.js +8 -3
- package/dist/lib/browser/profiles.d.ts +35 -101
- package/dist/lib/browser/profiles.js +130 -301
- package/dist/lib/browser/registry.d.ts +39 -0
- package/dist/lib/browser/registry.js +149 -0
- package/dist/lib/browser/remote-control.d.ts +8 -6
- package/dist/lib/browser/remote-control.js +8 -6
- package/dist/lib/browser/resolve-target.d.ts +68 -0
- package/dist/lib/browser/resolve-target.js +250 -0
- package/dist/lib/browser/runtime-state.d.ts +40 -16
- package/dist/lib/browser/runtime-state.js +92 -25
- package/dist/lib/browser/service.d.ts +28 -9
- package/dist/lib/browser/service.js +139 -62
- package/dist/lib/browser/task-index.d.ts +70 -0
- package/dist/lib/browser/task-index.js +180 -0
- package/dist/lib/byok-usage.js +2 -2
- package/dist/lib/claude-statusline.d.ts +29 -0
- package/dist/lib/claude-statusline.js +139 -0
- package/dist/lib/cloud/host.d.ts +1 -1
- package/dist/lib/cloud/host.js +1 -1
- package/dist/lib/cloud/rush.js +1 -1
- package/dist/lib/computer/computer-rpc.js +2 -2
- package/dist/lib/computer/download.d.ts +15 -15
- package/dist/lib/computer/download.js +35 -98
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
- package/dist/lib/computer/ssh-tunnel.js +12 -22
- package/dist/lib/config-keys.d.ts +2 -8
- package/dist/lib/config-keys.js +2 -13
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +2 -2
- package/dist/lib/crabbox/lease.d.ts +9 -0
- package/dist/lib/crabbox/lease.js +70 -15
- package/dist/lib/crabbox/runtimes.js +1 -1
- package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
- package/dist/lib/daemon/account-state-daemon-service.js +26 -0
- package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
- package/dist/lib/daemon/browser-ipc-service.js +46 -0
- package/dist/lib/daemon/daemon.d.ts +3 -0
- package/dist/lib/daemon/daemon.js +212 -444
- package/dist/lib/daemon/device-probe-service.d.ts +26 -0
- package/dist/lib/daemon/device-probe-service.js +47 -0
- package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
- package/dist/lib/daemon/keychain-reap-service.js +32 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
- package/dist/lib/daemon/monitor-engine-service.js +29 -0
- package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
- package/dist/lib/daemon/secrets-broker-service.js +66 -0
- package/dist/lib/daemon/self-heal-service.d.ts +27 -0
- package/dist/lib/daemon/self-heal-service.js +48 -0
- package/dist/lib/daemon/service.d.ts +102 -0
- package/dist/lib/daemon/service.js +98 -0
- package/dist/lib/daemon/session-index-service.d.ts +22 -0
- package/dist/lib/daemon/session-index-service.js +40 -0
- package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
- package/dist/lib/daemon/state-dir-check-service.js +63 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +298 -0
- package/dist/lib/daemon/watchdog-service.d.ts +19 -0
- package/dist/lib/daemon/watchdog-service.js +40 -0
- package/dist/lib/daemon-health.d.ts +15 -0
- package/dist/lib/daemon-health.js +29 -5
- package/dist/lib/daemon-services.d.ts +8 -1
- package/dist/lib/daemon-services.js +55 -0
- package/dist/lib/daemon-ticks.d.ts +13 -2
- package/dist/lib/daemon-ticks.js +20 -18
- package/dist/lib/device-config.d.ts +0 -2
- package/dist/lib/device-config.js +1 -23
- package/dist/lib/devices/registry.d.ts +1 -1
- package/dist/lib/devices/registry.js +1 -1
- package/dist/lib/devices/stats-cache.d.ts +20 -0
- package/dist/lib/devices/stats-cache.js +49 -5
- package/dist/lib/devices/worker-pick.d.ts +48 -0
- package/dist/lib/devices/worker-pick.js +113 -0
- package/dist/lib/exec.d.ts +67 -13
- package/dist/lib/exec.js +85 -29
- package/dist/lib/harness/adapters/cursor.js +8 -11
- package/dist/lib/helper-download.d.ts +108 -0
- package/dist/lib/helper-download.js +194 -0
- package/dist/lib/helper-versions.d.ts +52 -0
- package/dist/lib/helper-versions.js +58 -0
- package/dist/lib/hooks/cache.js +11 -0
- package/dist/lib/hooks/install.js +2 -2
- package/dist/lib/hosts/credentials.d.ts +1 -1
- package/dist/lib/hosts/credentials.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +7 -1
- package/dist/lib/hosts/dispatch.js +24 -5
- package/dist/lib/hosts/passthrough.d.ts +8 -15
- package/dist/lib/hosts/passthrough.js +19 -16
- package/dist/lib/hosts/providers/devices.d.ts +1 -1
- package/dist/lib/hosts/providers/devices.js +1 -1
- package/dist/lib/hosts/reconnect.d.ts +179 -21
- package/dist/lib/hosts/reconnect.js +266 -45
- package/dist/lib/installations/migrate.d.ts +4 -5
- package/dist/lib/installations/migrate.js +6 -7
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +23 -13
- package/dist/lib/installations/store.js +6 -3
- package/dist/lib/installations/versions.js +73 -48
- package/dist/lib/linear-autoclose.d.ts +1 -1
- package/dist/lib/linear-autoclose.js +1 -1
- package/dist/lib/loop.d.ts +2 -2
- package/dist/lib/loop.js +1 -1
- package/dist/lib/menubar/download-menubar.d.ts +47 -0
- package/dist/lib/menubar/download-menubar.js +60 -0
- package/dist/lib/menubar/install-menubar.d.ts +31 -7
- package/dist/lib/menubar/install-menubar.js +79 -21
- package/dist/lib/menubar/notify-desktop.d.ts +3 -3
- package/dist/lib/menubar/notify-desktop.js +6 -6
- package/dist/lib/model-tiers.d.ts +1 -1
- package/dist/lib/project-probe.d.ts +1 -1
- package/dist/lib/project-probe.js +1 -1
- package/dist/lib/project-pull.d.ts +1 -1
- package/dist/lib/project-pull.js +2 -2
- package/dist/lib/projects.d.ts +1 -1
- package/dist/lib/projects.js +1 -1
- package/dist/lib/sandbox.js +6 -7
- package/dist/lib/secrets/download-keychain.d.ts +47 -0
- package/dist/lib/secrets/download-keychain.js +70 -0
- package/dist/lib/secrets/install-helper.d.ts +20 -1
- package/dist/lib/secrets/install-helper.js +44 -3
- package/dist/lib/session/active.js +17 -1
- package/dist/lib/session/db.d.ts +11 -1
- package/dist/lib/session/db.js +138 -6
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +33 -5
- package/dist/lib/session/highlights.d.ts +49 -1
- package/dist/lib/session/highlights.js +95 -0
- package/dist/lib/session/host-link.d.ts +16 -2
- package/dist/lib/session/host-link.js +7 -1
- package/dist/lib/session/parse.js +33 -1
- package/dist/lib/session/remote/remote-list.d.ts +9 -0
- package/dist/lib/session/remote/remote-list.js +19 -2
- package/dist/lib/session/remote/watch.js +11 -2
- package/dist/lib/session/session-cache.d.ts +53 -0
- package/dist/lib/session/session-cache.js +88 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/tool-calls.d.ts +2 -0
- package/dist/lib/session/tool-calls.js +1 -1
- package/dist/lib/session/trajectory-html.d.ts +5 -1
- package/dist/lib/session/trajectory-html.js +42 -22
- package/dist/lib/session/trajectory-text.js +9 -2
- package/dist/lib/session/trajectory.js +53 -4
- package/dist/lib/session/types.d.ts +15 -0
- package/dist/lib/sha256-asset.d.ts +26 -0
- package/dist/lib/sha256-asset.js +41 -0
- package/dist/lib/share/backend.d.ts +95 -0
- package/dist/lib/share/backend.js +139 -0
- package/dist/lib/share/delete.d.ts +6 -2
- package/dist/lib/share/delete.js +8 -9
- package/dist/lib/share/html.d.ts +22 -0
- package/dist/lib/share/html.js +88 -0
- package/dist/lib/share/provision.d.ts +18 -4
- package/dist/lib/share/provision.js +38 -4
- package/dist/lib/share/publish.d.ts +44 -15
- package/dist/lib/share/publish.js +58 -26
- package/dist/lib/share/worker-template.js +402 -25
- package/dist/lib/sqlite.js +16 -3
- package/dist/lib/ssh-exec.d.ts +65 -0
- package/dist/lib/ssh-exec.js +120 -4
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/startup/dev-build.js +3 -3
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +7 -3
- package/dist/lib/subagents-registry.js +1 -1
- package/dist/lib/teams/agents.d.ts +14 -12
- package/dist/lib/teams/agents.js +188 -73
- package/dist/lib/teams/api.d.ts +6 -1
- package/dist/lib/teams/api.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/teams/scheduler.js +26 -8
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/terminal/types.js +1 -1
- package/dist/lib/tmux/orphan-reap.js +1 -1
- package/dist/lib/tmux/session.js +31 -0
- package/dist/lib/traces/backend.d.ts +29 -0
- package/dist/lib/traces/backend.js +46 -0
- package/dist/lib/traces/classify.d.ts +52 -0
- package/dist/lib/traces/classify.js +72 -0
- package/dist/lib/traces/config.d.ts +10 -0
- package/dist/lib/traces/config.js +2 -0
- package/dist/lib/traces/provision.d.ts +19 -0
- package/dist/lib/traces/provision.js +30 -0
- package/dist/lib/traces/sync.d.ts +150 -0
- package/dist/lib/traces/sync.js +459 -0
- package/dist/lib/traces/worker-template.d.ts +4 -0
- package/dist/lib/traces/worker-template.js +159 -0
- package/dist/lib/types.d.ts +18 -19
- package/dist/lib/types.js +14 -0
- package/dist/lib/usage-refresh.d.ts +1 -1
- package/dist/lib/usage-refresh.js +11 -8
- package/dist/lib/workflows.d.ts +1 -1
- package/package.json +4 -6
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
- package/dist/lib/usage-fleet.d.ts +0 -32
- package/dist/lib/usage-fleet.js +0 -125
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
// The Cloudflare Worker that fronts the R2 share bucket.
|
|
2
2
|
//
|
|
3
3
|
// One tiny Worker does both sides:
|
|
4
|
-
// - PUT /<username>/<slug> — bearer-gated
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// - PUT /<username>/<slug> — bearer-gated by TWO principals (not a fallback):
|
|
5
|
+
// 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
|
|
6
|
+
// bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
|
|
7
|
+
// path's first segment.
|
|
8
|
+
// 2. Phoenix bearer — otherwise GET ${env.PHOENIX_ID_BASE}/api/v1/auth/me
|
|
9
|
+
// with that bearer → {userId,email}; 401 if absent/invalid.
|
|
10
|
+
// customMetadata.owner = userId (stable). The path's first segment MUST
|
|
11
|
+
// equal handleFromEmail(email) (the public handle, e.g. muqsitnawaz),
|
|
12
|
+
// so one user cannot write another's prefix (403 namespace mismatch).
|
|
13
|
+
// A __handles/<handle> claim binds the handle to the first userId that
|
|
14
|
+
// writes it; a different userId gets 409 handle taken.
|
|
15
|
+
// A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
|
|
16
|
+
// platform endpoint may set both. Fail loud (401) when neither authenticates.
|
|
17
|
+
// Writes the body to R2 via the BUCKET binding, storing visibility
|
|
18
|
+
// (public|unlisted|me|org), owner, org_domain (org only), an optional
|
|
19
|
+
// expires-at, plus provenance (agent/session/host/repo/date), a label, and
|
|
20
|
+
// any `--meta` entries in object metadata. me/org require a Phoenix
|
|
21
|
+
// identity (BYO WRITE_TOKEN cannot publish them). org from a public inbox
|
|
22
|
+
// domain is 400. Overwriting an existing slug first copies the current
|
|
23
|
+
// object to <slug>/rev-<ts>-<rand> (revision history) unless
|
|
9
24
|
// x-share-no-revision is set.
|
|
10
|
-
// - GET /<username>/<slug> — public
|
|
25
|
+
// - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
|
|
26
|
+
// Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
|
|
27
|
+
// HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
|
|
28
|
+
// Phoenix login (or 401 JSON if PHOENIX_ID_BASE is unset). 410s (and lazily
|
|
11
29
|
// deletes) once its expiry has passed. A bucket lifecycle rule is the durable
|
|
12
30
|
// sweeper; this is the immediate gate.
|
|
13
31
|
// - GET /<username>/<slug>?revisions=json — machine-readable history of the
|
|
@@ -39,6 +57,10 @@
|
|
|
39
57
|
export function renderWorkerScript() {
|
|
40
58
|
return `// GENERATED by agents-cli agents share setup — do not edit here; edit
|
|
41
59
|
// src/lib/share/worker-template.ts and re-run setup.
|
|
60
|
+
export const hooks = {
|
|
61
|
+
verifyPhoenixToken: defaultVerifyPhoenixToken,
|
|
62
|
+
};
|
|
63
|
+
|
|
42
64
|
export default {
|
|
43
65
|
async fetch(request, env) {
|
|
44
66
|
const url = new URL(request.url);
|
|
@@ -50,15 +72,44 @@ export default {
|
|
|
50
72
|
});
|
|
51
73
|
}
|
|
52
74
|
|
|
75
|
+
const firstSeg = path.split('/').filter(Boolean)[0] || '';
|
|
76
|
+
if ((request.method === 'GET' || request.method === 'HEAD') && firstSeg.startsWith('__')) {
|
|
77
|
+
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
if (request.method === 'PUT') {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
81
|
+
const auth = await authorizeWrite(request, env);
|
|
82
|
+
if (auth.error) return auth.error;
|
|
83
|
+
const vis = normalizeVisibility(request.headers.get('x-share-visibility'));
|
|
84
|
+
if (vis.error) return vis.error;
|
|
85
|
+
const visibility = vis.value;
|
|
86
|
+
if (visibility === 'me' || visibility === 'org') {
|
|
87
|
+
const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
88
|
+
if (auth.kind !== 'phoenix' || !phoenixBase) {
|
|
89
|
+
return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
|
|
90
|
+
}
|
|
91
|
+
if (visibility === 'org') {
|
|
92
|
+
const domain = emailDomain(auth.email);
|
|
93
|
+
if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
|
|
94
|
+
if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) {
|
|
95
|
+
return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const segments = path.split('/').filter(Boolean);
|
|
100
|
+
if (auth.kind === 'phoenix') {
|
|
101
|
+
const expected = phoenixHandle(auth);
|
|
102
|
+
if (!expected || segments[0] !== expected) {
|
|
103
|
+
return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
104
|
+
}
|
|
105
|
+
const claimed = await claimHandle(env.BUCKET, expected, auth.owner);
|
|
106
|
+
if (claimed.error) return claimed.error;
|
|
57
107
|
}
|
|
58
108
|
const expiresAt = request.headers.get('x-share-expires-at') || '';
|
|
59
109
|
// 'unlisted' hides the page from the public gallery + JSON listing while
|
|
60
110
|
// keeping the direct URL world-readable (capability URL, not secret).
|
|
61
|
-
|
|
111
|
+
// me/org are also hidden from gallery/listing; GET is identity-gated
|
|
112
|
+
// and always sends X-Robots-Tag: noindex (same as unlisted).
|
|
62
113
|
const contentType = request.headers.get('content-type') || 'text/html; charset=utf-8';
|
|
63
114
|
// Provenance (RUSH-2683): captured client-side from the exec env/git/clock,
|
|
64
115
|
// never invented here — a header is simply absent when the CLI had nothing
|
|
@@ -93,7 +144,13 @@ export default {
|
|
|
93
144
|
delete customMetadata[reservedKey];
|
|
94
145
|
}
|
|
95
146
|
if (expiresAt) customMetadata['expires-at'] = expiresAt;
|
|
96
|
-
|
|
147
|
+
customMetadata['visibility'] = visibility;
|
|
148
|
+
const owner =
|
|
149
|
+
auth.kind === 'phoenix'
|
|
150
|
+
? auth.owner
|
|
151
|
+
: (env.SHARE_NAMESPACE || segments[0] || 'byo');
|
|
152
|
+
if (owner) customMetadata['owner'] = owner;
|
|
153
|
+
if (visibility === 'org') customMetadata['org_domain'] = emailDomain(auth.email);
|
|
97
154
|
if (agent) customMetadata['agent'] = agent;
|
|
98
155
|
if (session) customMetadata['session'] = session;
|
|
99
156
|
if (host) customMetadata['host'] = host;
|
|
@@ -129,7 +186,7 @@ export default {
|
|
|
129
186
|
httpMetadata: { contentType },
|
|
130
187
|
customMetadata,
|
|
131
188
|
});
|
|
132
|
-
return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted' }, 200);
|
|
189
|
+
return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
|
|
133
190
|
}
|
|
134
191
|
|
|
135
192
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
@@ -157,9 +214,22 @@ export default {
|
|
|
157
214
|
|
|
158
215
|
// Revision history for one canonical <user>/<slug> key (RUSH-2683). Checked
|
|
159
216
|
// before the plain object GET below so the query param routes even though
|
|
160
|
-
// the canonical key itself resolves to a real object.
|
|
217
|
+
// the canonical key itself resolves to a real object. me/org use the same
|
|
218
|
+
// identity gate as the page GET — listing session/host/repo on an unauthed
|
|
219
|
+
// ?revisions=json must not leak that the page exists.
|
|
161
220
|
if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
|
|
162
|
-
|
|
221
|
+
const canonical = await env.BUCKET.get(path);
|
|
222
|
+
if (canonical) {
|
|
223
|
+
const denied = await gateRestrictedGet(request, env, url, canonical);
|
|
224
|
+
if (denied) return denied;
|
|
225
|
+
}
|
|
226
|
+
return renderRevisions(
|
|
227
|
+
env.BUCKET,
|
|
228
|
+
url.origin,
|
|
229
|
+
path,
|
|
230
|
+
request.method,
|
|
231
|
+
canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public'),
|
|
232
|
+
);
|
|
163
233
|
}
|
|
164
234
|
|
|
165
235
|
const obj = await env.BUCKET.get(path);
|
|
@@ -169,17 +239,43 @@ export default {
|
|
|
169
239
|
await env.BUCKET.delete(path);
|
|
170
240
|
return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
|
|
171
241
|
}
|
|
242
|
+
const denied = await gateRestrictedGet(request, env, url, obj);
|
|
243
|
+
if (denied) return denied;
|
|
244
|
+
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
172
245
|
const headers = new Headers();
|
|
173
246
|
obj.writeHttpMetadata(headers);
|
|
174
247
|
headers.set('etag', obj.httpEtag);
|
|
175
248
|
if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
|
|
176
|
-
|
|
249
|
+
if (visibility === 'me' || visibility === 'org') {
|
|
250
|
+
headers.set('cache-control', 'private, no-store');
|
|
251
|
+
headers.set('X-Robots-Tag', 'noindex');
|
|
252
|
+
} else {
|
|
253
|
+
headers.set('cache-control', 'public, max-age=60');
|
|
254
|
+
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
255
|
+
}
|
|
177
256
|
return new Response(request.method === 'HEAD' ? null : obj.body, { status: 200, headers });
|
|
178
257
|
}
|
|
179
258
|
|
|
180
259
|
if (request.method === 'DELETE') {
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
260
|
+
const auth = await authorizeWrite(request, env);
|
|
261
|
+
if (auth.error) return auth.error;
|
|
262
|
+
if (auth.kind === 'phoenix') {
|
|
263
|
+
const handle = phoenixHandle(auth);
|
|
264
|
+
const uid = sanitizeNamespace(auth.owner);
|
|
265
|
+
const delSegments = path.split('/').filter(Boolean);
|
|
266
|
+
// Handle is the public namespace; userId prefix is still deletable so
|
|
267
|
+
// P1 UUID-namespaced objects the same account published can be taken down.
|
|
268
|
+
// A colliding local-part (same handle, different userId) must not be
|
|
269
|
+
// able to DELETE the claimant's pages — consult the claim the same way PUT does.
|
|
270
|
+
if (delSegments[0] && delSegments[0] === uid) {
|
|
271
|
+
// own leftover UUID prefix
|
|
272
|
+
} else if (delSegments[0] && delSegments[0] === handle) {
|
|
273
|
+
const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner);
|
|
274
|
+
if (owned.error) return owned.error;
|
|
275
|
+
} else {
|
|
276
|
+
return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
183
279
|
await env.BUCKET.delete(path);
|
|
184
280
|
return json({ ok: true, deleted: path }, 200);
|
|
185
281
|
}
|
|
@@ -210,8 +306,8 @@ async function renderGallery(bucket, origin, user, method) {
|
|
|
210
306
|
const activeObjects = objects.filter(o => {
|
|
211
307
|
if (isRevisionKey(o.key)) return false;
|
|
212
308
|
if (o.key.endsWith('.png')) return false;
|
|
213
|
-
// Unlisted pages are reachable by direct URL only — never on the gallery.
|
|
214
|
-
if (o.customMetadata && o.customMetadata.visibility
|
|
309
|
+
// Unlisted / me / org pages are reachable by direct URL only — never on the gallery.
|
|
310
|
+
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
|
|
215
311
|
const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
|
|
216
312
|
return !(expiresAt && Date.now() > Date.parse(expiresAt));
|
|
217
313
|
});
|
|
@@ -273,10 +369,10 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
273
369
|
const items = objects
|
|
274
370
|
.filter(o => {
|
|
275
371
|
// Mirror the gallery: hide revisions, sibling .png OG covers, expired
|
|
276
|
-
// pages, and unlisted pages — the listing is the machine-readable public surface.
|
|
372
|
+
// pages, and unlisted/me/org pages — the listing is the machine-readable public surface.
|
|
277
373
|
if (isRevisionKey(o.key)) return false;
|
|
278
374
|
if (o.key.endsWith('.png')) return false;
|
|
279
|
-
if (o.customMetadata && o.customMetadata.visibility
|
|
375
|
+
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
|
|
280
376
|
const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
|
|
281
377
|
return !(expiresAt && now > Date.parse(expiresAt));
|
|
282
378
|
})
|
|
@@ -307,7 +403,7 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
307
403
|
});
|
|
308
404
|
}
|
|
309
405
|
|
|
310
|
-
async function renderRevisions(bucket, origin, key, method) {
|
|
406
|
+
async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
311
407
|
const objects = [];
|
|
312
408
|
let cursor;
|
|
313
409
|
do {
|
|
@@ -333,12 +429,15 @@ async function renderRevisions(bucket, origin, key, method) {
|
|
|
333
429
|
// Newest first — the most recently replaced version leads.
|
|
334
430
|
items.sort((a, b) => (a.uploadedAt < b.uploadedAt ? 1 : a.uploadedAt > b.uploadedAt ? -1 : 0));
|
|
335
431
|
|
|
432
|
+
const cacheHeaders = identityGated
|
|
433
|
+
? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
|
|
434
|
+
: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
|
|
336
435
|
if (method === 'HEAD') {
|
|
337
|
-
return new Response(null, { status: 200, headers:
|
|
436
|
+
return new Response(null, { status: 200, headers: cacheHeaders });
|
|
338
437
|
}
|
|
339
438
|
return new Response(JSON.stringify({ key, count: items.length, revisions: items }), {
|
|
340
439
|
status: 200,
|
|
341
|
-
headers:
|
|
440
|
+
headers: cacheHeaders,
|
|
342
441
|
});
|
|
343
442
|
}
|
|
344
443
|
|
|
@@ -347,7 +446,10 @@ async function renderRevisions(bucket, origin, key, method) {
|
|
|
347
446
|
// before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
|
|
348
447
|
// One list, reused both to strip a same-named --meta collision on write and
|
|
349
448
|
// to split arbitrary --meta entries back out on read.
|
|
350
|
-
var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
|
|
449
|
+
var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
|
|
450
|
+
var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
|
|
451
|
+
var SHARE_COOKIE = '__Host-phoenix_share';
|
|
452
|
+
var SHARE_COOKIE_MAX_AGE = 604800;
|
|
351
453
|
|
|
352
454
|
// Everything in customMetadata that ISN'T one of the reserved provenance/label
|
|
353
455
|
// keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
|
|
@@ -383,5 +485,280 @@ function safeEqual(a, b) {
|
|
|
383
485
|
for (let i = 0; i < la; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i % lb || 0);
|
|
384
486
|
return out === 0 && la === lb;
|
|
385
487
|
}
|
|
488
|
+
|
|
489
|
+
// URL-safety, not collision-resistance. Two values that differ only in
|
|
490
|
+
// case/punctuation share a prefix.
|
|
491
|
+
function sanitizeNamespace(s) {
|
|
492
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Public handle: email local-part, plus-tag stripped. Must match CLI handleFromEmail.
|
|
496
|
+
function handleFromEmail(email) {
|
|
497
|
+
if (!email) return '';
|
|
498
|
+
const local = String(email).split('@')[0] || '';
|
|
499
|
+
const beforePlus = local.split('+')[0] || local;
|
|
500
|
+
return sanitizeNamespace(beforePlus);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function phoenixHandle(auth) {
|
|
504
|
+
return handleFromEmail(auth.email) || sanitizeNamespace(auth.owner);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// First writer of a handle owns it. Later PUTs from the same userId are fine;
|
|
508
|
+
// a different userId whose email local-part collides gets 409, not a silent overwrite.
|
|
509
|
+
async function assertHandleOwner(bucket, handle, userId) {
|
|
510
|
+
// Pages under the handle already stamp customMetadata.owner = userId.
|
|
511
|
+
// Treat that as the source of truth so a colliding local-part cannot
|
|
512
|
+
// PUT/DELETE another account's objects even if the claim object is missing.
|
|
513
|
+
const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
|
|
514
|
+
for (const o of list.objects || []) {
|
|
515
|
+
const owner = o.customMetadata && o.customMetadata.owner;
|
|
516
|
+
if (owner && owner !== userId) {
|
|
517
|
+
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const key = '__handles/' + handle;
|
|
521
|
+
const existing = await bucket.get(key);
|
|
522
|
+
if (!existing) return {};
|
|
523
|
+
const claimed = existing.customMetadata && existing.customMetadata.userId;
|
|
524
|
+
if (claimed && claimed !== userId) {
|
|
525
|
+
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
526
|
+
}
|
|
527
|
+
return {};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function claimHandle(bucket, handle, userId) {
|
|
531
|
+
const owned = await assertHandleOwner(bucket, handle, userId);
|
|
532
|
+
if (owned.error) return owned;
|
|
533
|
+
const key = '__handles/' + handle;
|
|
534
|
+
// Write (or rewrite) so a same-user republish resets object Age against the
|
|
535
|
+
// bucket's 366-day lifecycle — otherwise the claim can expire while pages stay live.
|
|
536
|
+
await bucket.put(key, JSON.stringify({ userId: userId }), {
|
|
537
|
+
httpMetadata: { contentType: 'application/json' },
|
|
538
|
+
customMetadata: { userId: userId, visibility: 'unlisted' },
|
|
539
|
+
});
|
|
540
|
+
return {};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeVisibility(raw) {
|
|
544
|
+
const v = (raw || '').trim().toLowerCase();
|
|
545
|
+
if (!v || v === 'public') return { value: 'public' };
|
|
546
|
+
if (v === 'unlisted' || v === 'me' || v === 'org') return { value: v };
|
|
547
|
+
return { error: json({ error: 'visibility must be public, unlisted, me, or org' }, 400) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function emailDomain(email) {
|
|
551
|
+
if (!email) return '';
|
|
552
|
+
const at = String(email).lastIndexOf('@');
|
|
553
|
+
if (at < 0) return '';
|
|
554
|
+
return String(email).slice(at + 1).toLowerCase();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function isHiddenFromGallery(visibility) {
|
|
558
|
+
return visibility === 'unlisted' || visibility === 'me' || visibility === 'org';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isIdentityGated(visibility) {
|
|
562
|
+
return visibility === 'me' || visibility === 'org';
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function gateRestrictedGet(request, env, url, obj) {
|
|
566
|
+
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
567
|
+
if (!isIdentityGated(visibility)) return null;
|
|
568
|
+
const viewer = await resolveViewer(request, env, url);
|
|
569
|
+
if (viewer.redirect) return viewer.redirect;
|
|
570
|
+
if (viewer.error) return viewer.error;
|
|
571
|
+
if (!viewer.identity) return bounceToLogin(url, env);
|
|
572
|
+
if (!viewerMayRead(visibility, obj.customMetadata, viewer.identity)) {
|
|
573
|
+
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
574
|
+
}
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function viewerMayRead(visibility, meta, identity) {
|
|
579
|
+
if (visibility === 'me') {
|
|
580
|
+
const owner = meta && meta.owner;
|
|
581
|
+
return !!owner && owner === identity.userId;
|
|
582
|
+
}
|
|
583
|
+
if (visibility === 'org') {
|
|
584
|
+
const stamped = meta && meta.org_domain;
|
|
585
|
+
if (!stamped) return false;
|
|
586
|
+
return emailDomain(identity.email) === String(stamped).toLowerCase();
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function bounceToLogin(url, env) {
|
|
592
|
+
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
593
|
+
if (!base) return json({ error: 'phoenix login is not configured' }, 401);
|
|
594
|
+
return new Response(null, {
|
|
595
|
+
status: 302,
|
|
596
|
+
headers: { Location: base + '/login?return=' + encodeURIComponent(url.toString()) },
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function readCookie(request, name) {
|
|
601
|
+
const header = request.headers.get('Cookie') || '';
|
|
602
|
+
const parts = header.split(';');
|
|
603
|
+
for (let i = 0; i < parts.length; i++) {
|
|
604
|
+
const p = parts[i];
|
|
605
|
+
const eq = p.indexOf('=');
|
|
606
|
+
if (eq < 0) continue;
|
|
607
|
+
if (p.slice(0, eq).trim() === name) return p.slice(eq + 1).trim();
|
|
608
|
+
}
|
|
609
|
+
return '';
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function toB64Url(s) {
|
|
613
|
+
const bytes = new TextEncoder().encode(s);
|
|
614
|
+
let bin = '';
|
|
615
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
616
|
+
return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function fromB64Url(s) {
|
|
620
|
+
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
|
621
|
+
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
622
|
+
const bin = atob(b64);
|
|
623
|
+
const bytes = new Uint8Array(bin.length);
|
|
624
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
625
|
+
return new TextDecoder().decode(bytes);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function bufToHex(buf) {
|
|
629
|
+
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function hmacHex(secret, data) {
|
|
633
|
+
const key = await crypto.subtle.importKey(
|
|
634
|
+
'raw',
|
|
635
|
+
new TextEncoder().encode(secret),
|
|
636
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
637
|
+
false,
|
|
638
|
+
['sign'],
|
|
639
|
+
);
|
|
640
|
+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
|
|
641
|
+
return bufToHex(sig);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function signShareCookie(identity, env) {
|
|
645
|
+
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
646
|
+
if (!secret) return '';
|
|
647
|
+
const exp = Math.floor(Date.now() / 1000) + SHARE_COOKIE_MAX_AGE;
|
|
648
|
+
const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
|
|
649
|
+
const sig = await hmacHex(secret, payload);
|
|
650
|
+
const value = toB64Url(payload) + '.' + sig;
|
|
651
|
+
return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function identityFromCookie(request, env) {
|
|
655
|
+
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
656
|
+
if (!secret) return null;
|
|
657
|
+
const raw = readCookie(request, SHARE_COOKIE);
|
|
658
|
+
if (!raw) return null;
|
|
659
|
+
const dot = raw.lastIndexOf('.');
|
|
660
|
+
if (dot < 1) return null;
|
|
661
|
+
let payload;
|
|
662
|
+
try {
|
|
663
|
+
payload = fromB64Url(raw.slice(0, dot));
|
|
664
|
+
} catch {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
const sig = raw.slice(dot + 1);
|
|
668
|
+
const expected = await hmacHex(secret, payload);
|
|
669
|
+
if (!safeEqual(sig, expected)) return null;
|
|
670
|
+
const first = payload.indexOf('|');
|
|
671
|
+
const last = payload.lastIndexOf('|');
|
|
672
|
+
if (first < 0 || last <= first) return null;
|
|
673
|
+
const userId = payload.slice(0, first);
|
|
674
|
+
const email = payload.slice(first + 1, last);
|
|
675
|
+
const cookieExp = Number(payload.slice(last + 1));
|
|
676
|
+
if (!userId || !Number.isFinite(cookieExp) || Math.floor(Date.now() / 1000) > cookieExp) return null;
|
|
677
|
+
return { userId: userId, email: email };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function redeemTicket(ticket, env) {
|
|
681
|
+
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
682
|
+
if (!base || !ticket) return null;
|
|
683
|
+
let res;
|
|
684
|
+
try {
|
|
685
|
+
res = await fetch(base + '/api/v1/auth/ticket', {
|
|
686
|
+
method: 'POST',
|
|
687
|
+
headers: { 'content-type': 'application/json' },
|
|
688
|
+
body: JSON.stringify({ ticket: ticket }),
|
|
689
|
+
});
|
|
690
|
+
} catch {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
if (!res.ok) return null;
|
|
694
|
+
let body;
|
|
695
|
+
try {
|
|
696
|
+
body = await res.json();
|
|
697
|
+
} catch {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
if (!body || typeof body.userId !== 'string' || !body.userId) return null;
|
|
701
|
+
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function resolveViewer(request, env, url) {
|
|
705
|
+
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
706
|
+
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
707
|
+
return { identity: { userId: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' } };
|
|
708
|
+
}
|
|
709
|
+
const cookieId = await identityFromCookie(request, env);
|
|
710
|
+
if (cookieId) return { identity: cookieId };
|
|
711
|
+
const ticket = url.searchParams.get('phoenix_ticket');
|
|
712
|
+
if (!ticket) return {};
|
|
713
|
+
const redeemed = await redeemTicket(ticket, env);
|
|
714
|
+
if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
|
|
715
|
+
const cookie = await signShareCookie(redeemed, env);
|
|
716
|
+
if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
|
|
717
|
+
url.searchParams.delete('phoenix_ticket');
|
|
718
|
+
const headers = new Headers();
|
|
719
|
+
headers.set('Location', url.toString());
|
|
720
|
+
headers.append('Set-Cookie', cookie);
|
|
721
|
+
return { redirect: new Response(null, { status: 302, headers: headers }) };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function defaultVerifyPhoenixToken(request, env) {
|
|
725
|
+
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
726
|
+
if (!presented) return null;
|
|
727
|
+
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
728
|
+
if (!base) return null;
|
|
729
|
+
let res;
|
|
730
|
+
try {
|
|
731
|
+
res = await fetch(base + '/api/v1/auth/me', {
|
|
732
|
+
method: 'GET',
|
|
733
|
+
headers: { Authorization: 'Bearer ' + presented },
|
|
734
|
+
});
|
|
735
|
+
} catch {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
if (!res.ok) return null;
|
|
739
|
+
let body;
|
|
740
|
+
try {
|
|
741
|
+
body = await res.json();
|
|
742
|
+
} catch {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
if (!body || typeof body.userId !== 'string' || !body.userId) return null;
|
|
746
|
+
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Two legitimate principals: WRITE_TOKEN equality first (admin/BYO), otherwise
|
|
750
|
+
// the bearer is a Phoenix token and we verify it. 401 when neither authenticates.
|
|
751
|
+
async function authorizeWrite(request, env) {
|
|
752
|
+
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
753
|
+
if (!presented) return { error: json({ error: 'unauthorized' }, 401) };
|
|
754
|
+
if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
|
|
755
|
+
return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
|
|
756
|
+
}
|
|
757
|
+
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
758
|
+
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
759
|
+
return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
|
|
760
|
+
}
|
|
761
|
+
return { error: json({ error: 'unauthorized' }, 401) };
|
|
762
|
+
}
|
|
386
763
|
`;
|
|
387
764
|
}
|
package/dist/lib/sqlite.js
CHANGED
|
@@ -41,10 +41,23 @@ function loadNodeSqlite() {
|
|
|
41
41
|
process.emitWarning = original;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
// Keep
|
|
45
|
-
// sqlite module as a userland package during test collection.
|
|
44
|
+
// Keep BOTH runtimes on createRequire() so Vitest doesn't try to prebundle the
|
|
45
|
+
// built-in sqlite module as a userland package during test collection.
|
|
46
|
+
//
|
|
47
|
+
// The Bun arm used to be `await import('bun:sqlite' as string)`, which made this
|
|
48
|
+
// a TOP-LEVEL AWAIT. esbuild cannot lower top-level await to CJS, so every test
|
|
49
|
+
// that spawns a subprocess through `tsx` (CJS mode) died at transform time with
|
|
50
|
+
// `Top-level await is currently not supported with the "cjs" output format` --
|
|
51
|
+
// 49 failures across 7 files, none of which touch sqlite. `require` is
|
|
52
|
+
// synchronous, so the await disappears and with it the whole failure class.
|
|
53
|
+
//
|
|
54
|
+
// The specifier is held in a variable, not written inline: a literal
|
|
55
|
+
// `require('bun:sqlite')` is statically analyzable, so bundlers and Vitest's
|
|
56
|
+
// collector try to resolve a module that does not exist off-Bun. That
|
|
57
|
+
// indirection is what the old `as string` cast was doing for the dynamic import.
|
|
58
|
+
const BUN_SQLITE = 'bun:sqlite';
|
|
46
59
|
const sqliteMod = isBun
|
|
47
|
-
?
|
|
60
|
+
? require(BUN_SQLITE)
|
|
48
61
|
: loadNodeSqlite();
|
|
49
62
|
// bun:sqlite exports `Database`; node:sqlite exports `DatabaseSync`.
|
|
50
63
|
const NativeDatabase = sqliteMod.Database
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* smuggled in as part of a remote command, and `sshExec` additionally rejects a
|
|
14
14
|
* leading `-` so it can never be parsed as an ssh argv flag.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* ssh's own connection-layer failure code — the transport dropped or never came
|
|
18
|
+
* up, as opposed to the remote command choosing an exit status. Defined here,
|
|
19
|
+
* in the layer that owns the ssh invocation, and re-exported by
|
|
20
|
+
* `lib/hosts/reconnect.ts` as `SSH_CONN_FAILURE` so there is one constant for
|
|
21
|
+
* the concept rather than two that can drift apart.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SSH_CONN_FAILURE_CODE = 255;
|
|
16
24
|
export declare const SSH_TARGET_RE: RegExp;
|
|
17
25
|
export declare function assertValidSshTarget(host: string): void;
|
|
18
26
|
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
@@ -156,11 +164,68 @@ export interface SshStreamOptions {
|
|
|
156
164
|
/** Additional OpenSSH argv placed before the target (for example `-i <path>`). */
|
|
157
165
|
extraSshArgs?: string[];
|
|
158
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The DEC private modes a full-screen remote app turns ON and, when it exits
|
|
169
|
+
* cleanly, turns back off itself. A remote agent TUI killed by a dropped link
|
|
170
|
+
* never gets to, so they stay enabled on the LOCAL terminal after ssh is gone:
|
|
171
|
+
* focus reporting (1004) and the mode/colour-scheme reports (996/997) then make
|
|
172
|
+
* the terminal emit answerback sequences at a shell that is not expecting them,
|
|
173
|
+
* which is the `^[[?997;1n ^[[I ^[[O` litter seen after a drop (RUSH-3125). Also
|
|
174
|
+
* resets bracketed paste (2004), the alternate screen (1049), mouse tracking
|
|
175
|
+
* (1000/1002/1003/1006), and re-shows the cursor (25) — every one of which a TUI
|
|
176
|
+
* leaves armed the same way.
|
|
177
|
+
*/
|
|
178
|
+
export declare const TERMINAL_MODE_RESET: string;
|
|
179
|
+
/**
|
|
180
|
+
* Put the local terminal back the way ssh found it.
|
|
181
|
+
*
|
|
182
|
+
* Two independent kinds of state survive a hard ssh death, and BOTH have to be
|
|
183
|
+
* undone or the next thing the user sees is corrupted:
|
|
184
|
+
*
|
|
185
|
+
* - **termios** (kernel side): `-tt` puts the tty in raw mode, so a killed ssh
|
|
186
|
+
* leaves it with echo and line-editing off. `saved` is the `stty -g` snapshot
|
|
187
|
+
* taken before the spawn; restoring it is exact, unlike guessing at `sane`.
|
|
188
|
+
* - **DEC private modes** (terminal side): see {@link TERMINAL_MODE_RESET}.
|
|
189
|
+
*
|
|
190
|
+
* Then drain whatever the terminal already answered back into the input buffer.
|
|
191
|
+
* Without the drain those bytes are not merely cosmetic — they are still queued
|
|
192
|
+
* on the tty, so the NEXT attach hands them straight to the agent as if the user
|
|
193
|
+
* had typed them.
|
|
194
|
+
*
|
|
195
|
+
* Best-effort by construction: this runs while recovering from a failure, so a
|
|
196
|
+
* missing `stty`, a non-TTY stdin, or a closed stream must never turn a
|
|
197
|
+
* recoverable drop into a thrown error.
|
|
198
|
+
*/
|
|
199
|
+
export declare function restoreLocalTerminal(saved: string | undefined, opts: {
|
|
200
|
+
drainStdin: boolean;
|
|
201
|
+
}): void;
|
|
202
|
+
/** `stty -g` snapshot of the local tty, or undefined when there is nothing to snapshot. */
|
|
203
|
+
export declare function saveLocalTerminal(): string | undefined;
|
|
159
204
|
/**
|
|
160
205
|
* Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
|
|
161
206
|
* local stdio wired straight through (`stdio: 'inherit'`), so output streams as
|
|
162
207
|
* it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
|
|
163
208
|
* until the remote command exits and returns its exit code (255 is ssh's own
|
|
164
209
|
* connection-layer failure; any other non-zero is the remote command's code).
|
|
210
|
+
*
|
|
211
|
+
* On a `tty` stream the local terminal state is snapshotted before the spawn and
|
|
212
|
+
* restored after it. ssh restores termios itself when it exits cleanly, so this
|
|
213
|
+
* is for the case that matters here: the link dying under a full-screen agent
|
|
214
|
+
* TUI, which leaves the tty raw and the TUI's DEC modes armed. Doing it HERE
|
|
215
|
+
* rather than in the reconnect loop means every caller that opens an interactive
|
|
216
|
+
* remote stream is covered, not just the one that noticed (RUSH-3125).
|
|
217
|
+
*
|
|
218
|
+
* **The stdin drain is gated on an ABNORMAL exit, and the distinction is not
|
|
219
|
+
* cosmetic.** Resetting termios and the DEC modes is idempotent — on a clean
|
|
220
|
+
* exit ssh and the remote TUI have already done it, so re-doing it changes
|
|
221
|
+
* nothing. Draining stdin is destructive: it discards whatever the user has
|
|
222
|
+
* typed ahead. On a clean exit those bytes are legitimate type-ahead the
|
|
223
|
+
* resuming shell should receive, and eating them would be a new bug in every
|
|
224
|
+
* caller of this function (`runInteractiveOnHost`, the remote-tmux attach in
|
|
225
|
+
* commands/go.ts, the remote secrets browse) rather than a fix. Only an
|
|
226
|
+
* abnormal exit — ssh killed by a signal (`status === null`) or its own
|
|
227
|
+
* connection-layer failure (255) — produces the answerback storm the drain
|
|
228
|
+
* exists to clear, because only then did the TUI die without sending its own
|
|
229
|
+
* mode resets.
|
|
165
230
|
*/
|
|
166
231
|
export declare function sshStream(target: string, remoteCmd: string, opts?: SshStreamOptions): number;
|