@phnx-labs/agents-cli 1.22.74 → 1.22.76
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 +152 -0
- package/README.md +21 -9
- package/dist/bootstrap.js +7 -7
- package/dist/cli/command-registry.js +5 -0
- package/dist/commands/artifacts-setup.js +1 -1
- package/dist/commands/artifacts.js +1 -1
- package/dist/commands/auth.js +7 -1
- package/dist/commands/browser.js +104 -10
- package/dist/commands/commands.js +7 -6
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +26 -7
- package/dist/commands/config.js +27 -4
- package/dist/commands/cost.js +6 -4
- package/dist/commands/doctor.d.ts +6 -5
- package/dist/commands/doctor.js +32 -274
- package/dist/commands/exec.d.ts +2 -0
- package/dist/commands/exec.js +9 -2
- package/dist/commands/harness.d.ts +1 -0
- package/dist/commands/harness.js +11 -3
- package/dist/commands/hooks.js +7 -6
- package/dist/commands/mcp.js +7 -6
- package/dist/commands/memory.js +7 -7
- package/dist/commands/monitors.js +3 -2
- package/dist/commands/open.d.ts +25 -12
- package/dist/commands/open.js +24 -10
- package/dist/commands/permissions.js +7 -6
- package/dist/commands/plugins.js +21 -17
- package/dist/commands/route.js +33 -16
- package/dist/commands/rules.js +7 -12
- package/dist/commands/sessions-share.js +1 -1
- package/dist/commands/setup-watchdog.js +2 -2
- package/dist/commands/setup.js +22 -1
- package/dist/commands/share.js +26 -10
- package/dist/commands/skills.js +7 -6
- package/dist/commands/subagents.js +7 -6
- package/dist/commands/sync.js +81 -10
- package/dist/commands/view.js +4 -1
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +10 -10
- package/dist/commands/webhook.d.ts +4 -0
- package/dist/commands/webhook.js +22 -4
- package/dist/commands/workflows.js +7 -6
- package/dist/lib/account-registry.js +27 -6
- package/dist/lib/accounting/rotate.d.ts +3 -1
- package/dist/lib/accounting/rotate.js +8 -4
- package/dist/lib/auth-health.d.ts +2 -0
- package/dist/lib/auth-health.js +2 -0
- package/dist/lib/browser/chrome.d.ts +21 -0
- package/dist/lib/browser/chrome.js +60 -3
- package/dist/lib/browser/drivers/local.d.ts +21 -0
- package/dist/lib/browser/drivers/local.js +102 -9
- package/dist/lib/browser/profiles.d.ts +29 -1
- package/dist/lib/browser/profiles.js +50 -1
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/computer/computer-rpc.d.ts +6 -1
- package/dist/lib/computer/computer-rpc.js +23 -3
- package/dist/lib/computer/des.d.ts +1 -0
- package/dist/lib/computer/des.js +114 -0
- package/dist/lib/computer/rfb-client.d.ts +53 -0
- package/dist/lib/computer/rfb-client.js +562 -0
- package/dist/lib/config-keys.d.ts +7 -2
- package/dist/lib/config-keys.js +17 -2
- package/dist/lib/daemon/auth-sync-service.js +3 -0
- package/dist/lib/daemon/daemon.js +17 -10
- package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
- package/dist/lib/daemon/session-summarizer-service.js +39 -0
- package/dist/lib/daemon/usage-sync-service.js +3 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/daemon-ticks.d.ts +2 -2
- package/dist/lib/daemon-ticks.js +2 -1
- package/dist/lib/daemon-webhooks.js +15 -2
- package/dist/lib/deeplink/register.js +10 -9
- package/dist/lib/deeplink/url.d.ts +4 -4
- package/dist/lib/deeplink/url.js +4 -4
- package/dist/lib/device-config.js +25 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -4
- package/dist/lib/devices/doctor-findings.js +14 -8
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/stats-cache.d.ts +4 -0
- package/dist/lib/devices/stats-cache.js +19 -0
- package/dist/lib/drift-sync.d.ts +3 -1
- package/dist/lib/drift-sync.js +16 -5
- package/dist/lib/exec.d.ts +2 -0
- package/dist/lib/exec.js +16 -1
- package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
- package/dist/lib/fleet-shared-repo-sync.js +101 -4
- package/dist/lib/fleet-shared-state.d.ts +8 -0
- package/dist/lib/heal.d.ts +4 -3
- package/dist/lib/heal.js +5 -4
- package/dist/lib/hosts/ready.d.ts +1 -1
- package/dist/lib/hosts/ready.js +16 -4
- package/dist/lib/hosts/reconnect.js +4 -2
- package/dist/lib/identity/client.d.ts +6 -0
- package/dist/lib/identity/index.d.ts +16 -0
- package/dist/lib/identity/index.js +25 -1
- package/dist/lib/profiles.d.ts +2 -0
- package/dist/lib/profiles.js +28 -9
- package/dist/lib/reconcile-and-repair.d.ts +109 -0
- package/dist/lib/reconcile-and-repair.js +267 -0
- package/dist/lib/routers.d.ts +12 -1
- package/dist/lib/routers.js +30 -1
- package/dist/lib/scheduling/routines.js +8 -2
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/db.d.ts +47 -7
- package/dist/lib/session/db.js +114 -12
- package/dist/lib/session/mirror.js +58 -0
- package/dist/lib/session/remote/remote-list.d.ts +2 -0
- package/dist/lib/session/remote/remote-list.js +4 -0
- package/dist/lib/session/remote/watch.js +22 -2
- package/dist/lib/session/session-cache.d.ts +19 -0
- package/dist/lib/session/session-cache.js +46 -0
- package/dist/lib/session/types.d.ts +34 -0
- package/dist/lib/share/backend.d.ts +6 -4
- package/dist/lib/share/backend.js +10 -8
- package/dist/lib/share/config.d.ts +4 -3
- package/dist/lib/share/config.js +10 -1
- package/dist/lib/share/delete.d.ts +1 -1
- package/dist/lib/share/delete.js +1 -1
- package/dist/lib/share/html.d.ts +1 -1
- package/dist/lib/share/html.js +1 -1
- package/dist/lib/share/provision.d.ts +1 -1
- package/dist/lib/share/provision.js +2 -2
- package/dist/lib/share/publish.d.ts +23 -7
- package/dist/lib/share/publish.js +58 -12
- package/dist/lib/share/worker-template.js +221 -60
- package/dist/lib/startup/command-registry.js +2 -2
- package/dist/lib/state.d.ts +15 -0
- package/dist/lib/state.js +29 -7
- package/dist/lib/summarizer/config.d.ts +46 -0
- package/dist/lib/summarizer/config.js +83 -0
- package/dist/lib/summarizer/pass.d.ts +45 -0
- package/dist/lib/summarizer/pass.js +112 -0
- package/dist/lib/summarizer/summarize.d.ts +68 -0
- package/dist/lib/summarizer/summarize.js +120 -0
- package/dist/lib/teams/agents.d.ts +4 -3
- package/dist/lib/teams/agents.js +12 -4
- package/dist/lib/teams/scheduler.d.ts +4 -2
- package/dist/lib/teams/scheduler.js +6 -6
- package/dist/lib/tmux/session.d.ts +2 -0
- package/dist/lib/tmux/session.js +7 -1
- package/dist/lib/types.d.ts +20 -0
- package/dist/lib/verbs.d.ts +23 -0
- package/dist/lib/verbs.js +24 -0
- package/dist/lib/view-types.d.ts +4 -0
- package/dist/lib/watchdog/rotate.d.ts +1 -1
- package/dist/lib/watchdog/rotate.js +1 -1
- package/package.json +1 -1
|
@@ -219,7 +219,7 @@ export async function enableWorkersDev(apiToken, accountId, workerName, opts = {
|
|
|
219
219
|
/** Resolve a zone id for a domain the token can see, or null if not owned/visible. */
|
|
220
220
|
export async function findZoneId(apiToken, domain, opts = {}) {
|
|
221
221
|
const request = opts.request ?? defaultCloudflareRequester;
|
|
222
|
-
// Try the exact name, then the registrable parent (share.
|
|
222
|
+
// Try the exact name, then the registrable parent (share.getrush.ai -> getrush.ai).
|
|
223
223
|
const candidates = [domain, domain.split('.').slice(-2).join('.')];
|
|
224
224
|
for (const name of candidates) {
|
|
225
225
|
const zones = await request({
|
|
@@ -232,7 +232,7 @@ export async function findZoneId(apiToken, domain, opts = {}) {
|
|
|
232
232
|
}
|
|
233
233
|
return null;
|
|
234
234
|
}
|
|
235
|
-
/** Map a custom hostname (e.g. `share.
|
|
235
|
+
/** Map a custom hostname (e.g. `share.getrush.ai`) to the Worker via Workers Custom Domains. */
|
|
236
236
|
export async function addCustomDomain(apiToken, accountId, workerName, zoneId, hostname, opts = {}) {
|
|
237
237
|
const request = opts.request ?? defaultCloudflareRequester;
|
|
238
238
|
try {
|
|
@@ -69,8 +69,17 @@ export interface PublishOptions {
|
|
|
69
69
|
analytics?: boolean;
|
|
70
70
|
/** Override the analytics token from share config. */
|
|
71
71
|
analyticsToken?: string;
|
|
72
|
-
/** Override the GitHub username used for the URL namespace. */
|
|
72
|
+
/** Override the GitHub username used for the URL namespace (BYO path). */
|
|
73
73
|
githubUser?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Override the managed public handle (PHNX-3547). Managed publishes normally
|
|
76
|
+
* namespace under the email local-part; when that handle is taken — or a
|
|
77
|
+
* vanity namespace is wanted — pass an alternate. Sanitized to
|
|
78
|
+
* `[a-z0-9-]` (max 63 chars, matching the Worker); the Worker binds it with
|
|
79
|
+
* the same first-writer claim as a derived handle. Ignored on BYO (use
|
|
80
|
+
* `githubUser` there).
|
|
81
|
+
*/
|
|
82
|
+
handle?: string;
|
|
74
83
|
/** DI seam for tests — override the persisted share endpoint config. */
|
|
75
84
|
config?: ShareConfig;
|
|
76
85
|
/** DI seam for tests — override the keychain-backed write token. Selects BYO. */
|
|
@@ -209,12 +218,15 @@ export declare function resolveShareProvenance(opts?: {
|
|
|
209
218
|
}): ShareProvenance;
|
|
210
219
|
/**
|
|
211
220
|
* The sharer's avatar URL, stamped so the share bar can show a real profile
|
|
212
|
-
* picture instead of only the initials circle.
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
221
|
+
* picture instead of only the initials circle. A hosted OAuth profile image
|
|
222
|
+
* already known to identity (PhoenixSession.avatarUrl — captured at login and
|
|
223
|
+
* refreshed from `/api/v1/auth/me`) wins outright; only when none exists do we
|
|
224
|
+
* fall back to a Gravatar keyed on the SHA-256 of the signed-in user's
|
|
225
|
+
* lowercased email (Gravatar resolves either MD5 or SHA-256), with `d=404` so
|
|
226
|
+
* Gravatar returns 404 for a user who has none — the bar's `<img>` onerror
|
|
227
|
+
* then falls back to the initials circle. Only the hash lands in public
|
|
228
|
+
* metadata, never the raw email. Returns '' when signed out (BYO without a
|
|
229
|
+
* Phoenix session), leaving the bar on the initials circle.
|
|
218
230
|
*
|
|
219
231
|
* `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
|
|
220
232
|
* yields ''; `undefined` reads the real persisted session.
|
|
@@ -369,4 +381,8 @@ export declare function resolveShareUsername(opts?: {
|
|
|
369
381
|
/** Build the R2 object key from a namespace username and a slug part. */
|
|
370
382
|
export declare function buildShareKey(username: string, slugPart: string): string;
|
|
371
383
|
export declare function publishFile(filePath: string, opts?: PublishOptions): Promise<PublishResult>;
|
|
384
|
+
/** Sanitize a caller-chosen managed handle to the Worker's namespace shape.
|
|
385
|
+
* Returns '' when the result is empty or over the Worker's 63-char cap (the
|
|
386
|
+
* caller then falls back to the derived handle / errors). */
|
|
387
|
+
export declare function resolveManagedHandle(handle: string | undefined): string;
|
|
372
388
|
export declare function publishToEndpoint(filePath: string, endpoint: PublishEndpoint, opts?: PublishOptions): Promise<PublishResult>;
|
|
@@ -129,18 +129,24 @@ export function resolveShareProvenance(opts = {}) {
|
|
|
129
129
|
}
|
|
130
130
|
/**
|
|
131
131
|
* The sharer's avatar URL, stamped so the share bar can show a real profile
|
|
132
|
-
* picture instead of only the initials circle.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
132
|
+
* picture instead of only the initials circle. A hosted OAuth profile image
|
|
133
|
+
* already known to identity (PhoenixSession.avatarUrl — captured at login and
|
|
134
|
+
* refreshed from `/api/v1/auth/me`) wins outright; only when none exists do we
|
|
135
|
+
* fall back to a Gravatar keyed on the SHA-256 of the signed-in user's
|
|
136
|
+
* lowercased email (Gravatar resolves either MD5 or SHA-256), with `d=404` so
|
|
137
|
+
* Gravatar returns 404 for a user who has none — the bar's `<img>` onerror
|
|
138
|
+
* then falls back to the initials circle. Only the hash lands in public
|
|
139
|
+
* metadata, never the raw email. Returns '' when signed out (BYO without a
|
|
140
|
+
* Phoenix session), leaving the bar on the initials circle.
|
|
138
141
|
*
|
|
139
142
|
* `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
|
|
140
143
|
* yields ''; `undefined` reads the real persisted session.
|
|
141
144
|
*/
|
|
142
145
|
export function resolveShareAvatar(opts = {}) {
|
|
143
146
|
const session = opts.session !== undefined ? opts.session : readSession();
|
|
147
|
+
const hosted = session?.avatarUrl?.trim();
|
|
148
|
+
if (hosted && /^https:\/\//i.test(hosted))
|
|
149
|
+
return hosted;
|
|
144
150
|
const email = session?.email?.trim().toLowerCase();
|
|
145
151
|
if (!email)
|
|
146
152
|
return '';
|
|
@@ -553,8 +559,9 @@ export function buildShareKey(username, slugPart) {
|
|
|
553
559
|
}
|
|
554
560
|
export async function publishFile(filePath, opts = {}) {
|
|
555
561
|
const backend = resolveShareBackend(opts);
|
|
562
|
+
const managedHandle = backend.kind === 'managed' ? requireManagedHandle(opts.handle) : '';
|
|
556
563
|
const username = backend.kind === 'managed'
|
|
557
|
-
? backend.namespace
|
|
564
|
+
? managedHandle || backend.namespace
|
|
558
565
|
: await resolveShareUsername({ githubUser: opts.githubUser || backend.namespace || undefined });
|
|
559
566
|
const analyticsToken = opts.analyticsToken ?? (backend.kind === 'byo' ? (opts.config ?? readShareConfig())?.analyticsToken : undefined);
|
|
560
567
|
return publishToEndpoint(filePath, { baseUrl: backend.baseUrl, token: backend.token }, {
|
|
@@ -564,8 +571,28 @@ export async function publishFile(filePath, opts = {}) {
|
|
|
564
571
|
backendKind: backend.kind,
|
|
565
572
|
});
|
|
566
573
|
}
|
|
574
|
+
/** Sanitize a caller-chosen managed handle to the Worker's namespace shape.
|
|
575
|
+
* Returns '' when the result is empty or over the Worker's 63-char cap (the
|
|
576
|
+
* caller then falls back to the derived handle / errors). */
|
|
577
|
+
export function resolveManagedHandle(handle) {
|
|
578
|
+
if (!handle)
|
|
579
|
+
return '';
|
|
580
|
+
const sanitized = sanitizeShareNamespace(handle);
|
|
581
|
+
return sanitized && sanitized.length <= 63 ? sanitized : '';
|
|
582
|
+
}
|
|
583
|
+
function requireManagedHandle(handle) {
|
|
584
|
+
const resolved = resolveManagedHandle(handle);
|
|
585
|
+
if (handle && !resolved) {
|
|
586
|
+
throw new Error(`Invalid --handle '${handle}': must sanitize to 1-63 [a-z0-9-] characters`);
|
|
587
|
+
}
|
|
588
|
+
return resolved;
|
|
589
|
+
}
|
|
567
590
|
export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
568
|
-
|
|
591
|
+
// A managed publish may carry an explicit --handle: it namespaces the URL and
|
|
592
|
+
// rides the x-share-handle header so the Worker binds the claim to it (a
|
|
593
|
+
// derived handle is proven by the email; an alternate one must be declared).
|
|
594
|
+
const managedHandle = opts.backendKind === 'managed' ? requireManagedHandle(opts.handle) : '';
|
|
595
|
+
const username = managedHandle || (await resolveShareUsername(opts));
|
|
569
596
|
let body = readFileSync(filePath);
|
|
570
597
|
const expiresAt = resolveExpire(opts.expire);
|
|
571
598
|
const visibility = resolveShareVisibility(opts);
|
|
@@ -666,6 +693,8 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
666
693
|
assertMetadataSize(metadataPreview);
|
|
667
694
|
const authHeaders = (contentType) => {
|
|
668
695
|
const h = { authorization: `Bearer ${endpoint.token}`, 'content-type': contentType };
|
|
696
|
+
if (managedHandle)
|
|
697
|
+
h['x-share-handle'] = managedHandle;
|
|
669
698
|
if (expiresAt)
|
|
670
699
|
h['x-share-expires-at'] = expiresAt;
|
|
671
700
|
h['x-share-visibility'] = visibility;
|
|
@@ -748,11 +777,28 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
748
777
|
}
|
|
749
778
|
const r = await put(pageUrl, body, authHeaders(opts.contentType ?? guessContentType(filePath)));
|
|
750
779
|
if (!r.ok) {
|
|
751
|
-
|
|
752
|
-
|
|
780
|
+
// 409 has two distinct shapes on the managed Worker: 'handle taken' (the
|
|
781
|
+
// caller's namespace belongs to another account) and 'publish conflict'
|
|
782
|
+
// (a concurrent write won the republish race — retry, per PHNX-3547).
|
|
783
|
+
const httpError = extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter });
|
|
784
|
+
if (r.status === 409 && httpError.serverMessage === 'handle taken' && opts.backendKind === 'managed') {
|
|
785
|
+
throw new Error(`Handle '${username}' is already claimed by another account. ` +
|
|
786
|
+
'If you signed in again and got a new account id, republishing with the same email re-binds your handle automatically; ' +
|
|
787
|
+
'otherwise pick a different public namespace with --handle <name>.');
|
|
788
|
+
}
|
|
789
|
+
const detail = formatShareHttpErrorDetail(httpError);
|
|
790
|
+
// The write-token/setup advice is only meaningful for an auth failure — a
|
|
791
|
+
// 413 (quota/size) or 429 (rate) rejection has nothing to do with the
|
|
792
|
+
// token, and 'Check the write token' there is plain wrong (PHNX-3579).
|
|
793
|
+
// Managed endpoints carry a Phoenix bearer, so the recovery is re-login.
|
|
794
|
+
let advice = '';
|
|
795
|
+
if (r.status === 401 || r.status === 403) {
|
|
796
|
+
advice =
|
|
797
|
+
opts.backendKind === 'managed'
|
|
798
|
+
? ". Check that you're signed in — run 'agents auth login'."
|
|
799
|
+
: ". Check the write token, or that 'agents artifacts setup' completed.";
|
|
753
800
|
}
|
|
754
|
-
|
|
755
|
-
throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}. Check the write token, or that 'agents artifacts setup' completed.`);
|
|
801
|
+
throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}${advice}`);
|
|
756
802
|
}
|
|
757
803
|
// A token-gated page is only reachable WITH its key, so the URL we hand back
|
|
758
804
|
// (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
|
|
@@ -151,11 +151,22 @@ export default {
|
|
|
151
151
|
}
|
|
152
152
|
const segments = path.split('/').filter(Boolean);
|
|
153
153
|
if (auth.kind === 'phoenix') {
|
|
154
|
-
|
|
154
|
+
// The caller's handle is normally derived from the email local-part. An
|
|
155
|
+
// explicit x-share-handle (the CLI's --handle, PHNX-3547) lets a Phoenix
|
|
156
|
+
// user choose a DIFFERENT free handle — the escape hatch when their
|
|
157
|
+
// derived handle is taken or they want a vanity namespace. Sanitized to
|
|
158
|
+
// the same [a-z0-9-] shape; claim rules below bind it first-writer-writes,
|
|
159
|
+
// exactly like a derived handle.
|
|
160
|
+
const requestedRaw = request.headers.get('x-share-handle') || '';
|
|
161
|
+
const requested = sanitizeNamespace(requestedRaw);
|
|
162
|
+
if (requestedRaw && (!requested || requested.length > 63)) {
|
|
163
|
+
return json({ error: 'invalid handle', handle: requestedRaw }, 400);
|
|
164
|
+
}
|
|
165
|
+
const expected = requested || phoenixHandle(auth);
|
|
155
166
|
if (!expected || segments[0] !== expected) {
|
|
156
167
|
return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
157
168
|
}
|
|
158
|
-
const claimed = await claimHandle(env.BUCKET, expected, auth.owner);
|
|
169
|
+
const claimed = await claimHandle(env.BUCKET, expected, auth.owner, auth.email || '');
|
|
159
170
|
if (claimed.error) return claimed.error;
|
|
160
171
|
}
|
|
161
172
|
const expiresAt = request.headers.get('x-share-expires-at') || '';
|
|
@@ -268,11 +279,8 @@ export default {
|
|
|
268
279
|
// publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
|
|
269
280
|
// WRITE_TOKEN publish writes to the operator's OWN bucket at their own
|
|
270
281
|
// cost, so it skips all four — a deliberate, documented policy, NOT a
|
|
271
|
-
// silent no-op.
|
|
272
|
-
//
|
|
273
|
-
// skips the read entirely (nothing consumes it).
|
|
274
|
-
const needExisting = !noRevision || auth.kind === 'phoenix';
|
|
275
|
-
const existing = needExisting ? await env.BUCKET.get(path) : null;
|
|
282
|
+
// silent no-op.
|
|
283
|
+
//
|
|
276
284
|
// Enforcement measures the REAL request body, never a client-declared size.
|
|
277
285
|
// A spoofed-low content-length must NOT (a) slip an oversized body past the
|
|
278
286
|
// per-file cap, (b) let real bytes exceed the total quota, or — most
|
|
@@ -281,8 +289,10 @@ export default {
|
|
|
281
289
|
// Phoenix write we buffer the body bounded by the plan's per-file cap and
|
|
282
290
|
// reject on the REAL size BEFORE any write; only then do we copy the
|
|
283
291
|
// revision and store the buffered bytes. BYO streams unbuffered (uncapped,
|
|
284
|
-
// its own bucket)
|
|
292
|
+
// its own bucket) — which also means a BYO body cannot be re-read for a
|
|
293
|
+
// retry, so BYO gets a single conditional attempt below.
|
|
285
294
|
let putBody = request.body;
|
|
295
|
+
let realBytes = 0;
|
|
286
296
|
if (auth.kind === 'phoenix') {
|
|
287
297
|
const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
|
|
288
298
|
// Fast-reject an HONEST oversized content-length without reading the body.
|
|
@@ -290,47 +300,101 @@ export default {
|
|
|
290
300
|
// read below, which measures the truth.
|
|
291
301
|
const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
|
|
292
302
|
if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
|
|
293
|
-
return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
|
|
303
|
+
return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
|
|
294
304
|
}
|
|
295
305
|
// readBodyBounded aborts the moment it passes the cap, so a chunked/
|
|
296
306
|
// streaming body can never buffer more than the cap (+ one chunk).
|
|
297
307
|
const read = await readBodyBounded(request, limits.maxFileBytes);
|
|
298
308
|
if (read.oversize) {
|
|
299
|
-
return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
|
|
309
|
+
return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
|
|
300
310
|
}
|
|
301
|
-
|
|
302
|
-
const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
|
|
303
|
-
const newCanonical = !existing;
|
|
304
|
-
// Keeping a revision retains the old canonical bytes AND adds the new
|
|
305
|
-
// ones, so storage grows by the full new size. A no-revision or first
|
|
306
|
-
// publish grows by new minus the bytes it replaces (may be negative on a
|
|
307
|
-
// shrink; the ledger clamps at >= 0).
|
|
308
|
-
const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
|
|
309
|
-
const charged = await chargeShareWrite(env, auth, {
|
|
310
|
-
charge: charge,
|
|
311
|
-
newCanonical: newCanonical,
|
|
312
|
-
fileBytes: realBytes,
|
|
313
|
-
countRate: true,
|
|
314
|
-
});
|
|
315
|
-
if (charged.error) return charged.error; // rejected BEFORE any destructive write
|
|
311
|
+
realBytes = read.size;
|
|
316
312
|
putBody = read.bytes;
|
|
317
313
|
}
|
|
318
314
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
315
|
+
// Revision retention + quota charge + canonical write as a BOUNDED
|
|
316
|
+
// compare-and-swap loop (PHNX-3547). The old code read the current object,
|
|
317
|
+
// archived it, then overwrote the canonical key UNCONDITIONALLY: two
|
|
318
|
+
// concurrent republishers both archived the same old version and the
|
|
319
|
+
// loser's new body ended up neither canonical nor retained — silently
|
|
320
|
+
// discarded. R2 has no transactions, so each attempt re-reads the canonical
|
|
321
|
+
// object, archives it as a revision, and overwrites ONLY while the etag
|
|
322
|
+
// still matches the read (onlyIf.etagMatches — the same CAS primitive the
|
|
323
|
+
// PATCH path at :513 and the usage ledger already rely on; R2 returns null
|
|
324
|
+
// on the mismatch instead of storing). A conflicted attempt therefore loses
|
|
325
|
+
// nothing: it re-reads the winner's body, archives THAT as the revision on
|
|
326
|
+
// the next attempt, and lands its own body canonical — both writers survive.
|
|
327
|
+
// Phoenix bytes are buffered above, so retrying is safe; the rate counter
|
|
328
|
+
// and object count advance once (attempt 0) and a conflicted re-charge only
|
|
329
|
+
// bills the growth beyond what this request already paid. A BYO stream is
|
|
330
|
+
// consumed by the first attempt, so it gets one conditional try and a 409
|
|
331
|
+
// asking the caller to retry the whole publish.
|
|
332
|
+
const MAX_PUT_ATTEMPTS = 3;
|
|
333
|
+
let chargedAlready = 0;
|
|
334
|
+
let chargedNewCanonical = false;
|
|
335
|
+
let putResult = null;
|
|
336
|
+
for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt++) {
|
|
337
|
+
// The current object is needed for BOTH the revision copy and the charge
|
|
338
|
+
// math, and even a first/no-revision publish must read before its
|
|
339
|
+
// conditional write so two concurrent creates cannot both report 200.
|
|
340
|
+
const existing = await env.BUCKET.get(path);
|
|
341
|
+
if (auth.kind === 'phoenix') {
|
|
342
|
+
const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
|
|
343
|
+
const newCanonical = !existing;
|
|
344
|
+
// Keeping a revision retains the old canonical bytes AND adds the new
|
|
345
|
+
// ones, so storage grows by the full new size. A no-revision or first
|
|
346
|
+
// publish grows by new minus the bytes it replaces (may be negative on a
|
|
347
|
+
// shrink; the ledger clamps at >= 0).
|
|
348
|
+
const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
|
|
349
|
+
// Conflict retry: only the growth beyond what this request already
|
|
350
|
+
// paid. Exact accounting under a lost race is impossible without
|
|
351
|
+
// reconciling the bucket; this stays the ledger's documented
|
|
352
|
+
// best-effort, same as its >= 0 clamp.
|
|
353
|
+
const bill = Math.max(0, charge - chargedAlready);
|
|
354
|
+
chargedAlready += bill;
|
|
355
|
+
const chargeNewCanonical = newCanonical && attempt === 0;
|
|
356
|
+
const charged = await chargeShareWrite(env, auth, {
|
|
357
|
+
charge: bill,
|
|
358
|
+
newCanonical: chargeNewCanonical,
|
|
359
|
+
fileBytes: realBytes,
|
|
360
|
+
countRate: attempt === 0,
|
|
361
|
+
});
|
|
362
|
+
if (charged.error) return charged.error; // rejected BEFORE any destructive write
|
|
363
|
+
if (chargeNewCanonical) chargedNewCanonical = true;
|
|
364
|
+
}
|
|
329
365
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
366
|
+
if (!noRevision && existing) {
|
|
367
|
+
const existingHeaders = new Headers();
|
|
368
|
+
if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
|
|
369
|
+
const existingContentType = existingHeaders.get('content-type');
|
|
370
|
+
const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
371
|
+
await env.BUCKET.put(revKey, existing.body, {
|
|
372
|
+
httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
|
|
373
|
+
customMetadata: existing.customMetadata || {},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const putOpts = { httpMetadata: { contentType }, customMetadata };
|
|
378
|
+
// Existing objects use the bare R2Object#etag for CAS; missing objects
|
|
379
|
+
// use R2's create-only condition so a concurrent first writer wins loud.
|
|
380
|
+
if (existing && existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
|
|
381
|
+
else putOpts.onlyIf = { etagDoesNotMatch: '*' };
|
|
382
|
+
putResult = await env.BUCKET.put(path, putBody, putOpts);
|
|
383
|
+
if (putResult !== null) break;
|
|
384
|
+
// A failed create-only condition means another first publisher won.
|
|
385
|
+
// Do not turn that loser into a republish: the caller must see the race.
|
|
386
|
+
if (!existing) break;
|
|
387
|
+
if (auth.kind !== 'phoenix') break; // BYO stream is spent — cannot retry
|
|
388
|
+
}
|
|
389
|
+
if (putResult === null) {
|
|
390
|
+
if (auth.kind === 'phoenix') {
|
|
391
|
+
await refundShareWrite(env, auth.owner, {
|
|
392
|
+
refund: chargedAlready,
|
|
393
|
+
freeCanonical: chargedNewCanonical,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return json({ error: 'publish conflict: another write landed first, retry the publish' }, 409);
|
|
397
|
+
}
|
|
334
398
|
// A managed republish may change the title/description. Invalidate only
|
|
335
399
|
// its generated sibling so the next crawler receives a fresh card; BYO
|
|
336
400
|
// publishes send no OG metadata and keep their explicitly uploaded cover.
|
|
@@ -345,15 +409,36 @@ export default {
|
|
|
345
409
|
if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
|
|
346
410
|
if (auth.kind === 'phoenix') {
|
|
347
411
|
const expected = phoenixHandle(auth);
|
|
348
|
-
|
|
412
|
+
const handle = segments[0];
|
|
413
|
+
if (!expected || handle !== expected) {
|
|
414
|
+
// An alternate handle must already have a claim; unlike PUT, PATCH
|
|
415
|
+
// cannot create a new namespace as a side effect.
|
|
416
|
+
const claim = handle ? await env.BUCKET.get('__handles/' + handle) : null;
|
|
417
|
+
if (!claim) return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
418
|
+
}
|
|
419
|
+
// Use the same ownership path as PUT/DELETE so the same verified email
|
|
420
|
+
// under a new userId transfers the claim and re-stamps old pages before
|
|
421
|
+
// the per-object ownership check below.
|
|
422
|
+
const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
|
|
423
|
+
if (owned.error) return json({ error: 'forbidden' }, 403);
|
|
349
424
|
}
|
|
350
425
|
const existing = await env.BUCKET.get(path);
|
|
351
426
|
if (!existing) return json({ error: 'share not found', key: path }, 404);
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
// the
|
|
355
|
-
//
|
|
356
|
-
|
|
427
|
+
// Ownership was settled above, the same way DELETE settles it: WRITE_TOKEN
|
|
428
|
+
// is the endpoint owner/admin credential, and a Phoenix caller has proven
|
|
429
|
+
// the handle claim (or, pre-claim, that no rival userId owns the prefix).
|
|
430
|
+
// There is deliberately NO per-object owner comparison here. Pages in a
|
|
431
|
+
// claimed namespace can carry a stamp that is not the claim holder's
|
|
432
|
+
// userId — a BYO WRITE_TOKEN publish stamps owner = the namespace, a page
|
|
433
|
+
// from the same human's earlier userId that transferHandle never saw, or
|
|
434
|
+
// a page with no stamp at all — and the claim holder could DELETE every
|
|
435
|
+
// one of them yet was refused a visibility change (403 'forbidden'), which
|
|
436
|
+
// left confidential pages public with takedown as the only remedy. The
|
|
437
|
+
// claim is the authority. The stamp itself is deliberately NOT rewritten:
|
|
438
|
+
// the anonymous lazy-expiry path refunds the STAMPED owner's usage ledger
|
|
439
|
+
// (see the GET expiry branch + refundShareWrite), and a fleet/BYO page was
|
|
440
|
+
// never charged to a Phoenix ledger — re-stamping it to the caller would
|
|
441
|
+
// credit her quota with bytes and a slot she never paid for on expiry.
|
|
357
442
|
|
|
358
443
|
let edit;
|
|
359
444
|
try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
|
|
@@ -486,7 +571,7 @@ export default {
|
|
|
486
571
|
const viewer = await resolveViewer(request, env, url);
|
|
487
572
|
if (viewer.redirect) return viewer.redirect;
|
|
488
573
|
if (viewer.error) return viewer.error;
|
|
489
|
-
const denied = gateVisibility(url, env, canonical, viewer.identity || null);
|
|
574
|
+
const denied = await gateVisibility(url, env, canonical, viewer.identity || null);
|
|
490
575
|
if (denied) return denied;
|
|
491
576
|
}
|
|
492
577
|
// A private canonical gates its revision list on the viewer key too
|
|
@@ -524,7 +609,7 @@ export default {
|
|
|
524
609
|
if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
525
610
|
const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
|
|
526
611
|
if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
|
|
527
|
-
const denied = gateVisibility(url, env, page, viewer.identity || null);
|
|
612
|
+
const denied = await gateVisibility(url, env, page, viewer.identity || null);
|
|
528
613
|
if (denied) return denied;
|
|
529
614
|
// A token-gated page's cover is token-gated too (PHNX-3654): a crawler
|
|
530
615
|
// fetching <slug>.png without the key gets 404, so no preview leaks.
|
|
@@ -534,7 +619,7 @@ export default {
|
|
|
534
619
|
if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
|
|
535
620
|
const current = await env.BUCKET.get(pagePath);
|
|
536
621
|
if (!current || current.etag !== page.etag) { existingCover = null; continue; }
|
|
537
|
-
const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
|
|
622
|
+
const currentDenied = await gateVisibility(url, env, current, viewer.identity || null);
|
|
538
623
|
if (currentDenied) return currentDenied;
|
|
539
624
|
return new Response(request.method === 'HEAD' ? null : existingCover.body, {
|
|
540
625
|
status: 200,
|
|
@@ -619,7 +704,7 @@ export default {
|
|
|
619
704
|
if (viewer.redirect) return viewer.redirect;
|
|
620
705
|
if (viewer.error && isIdentityGated(visibility)) return viewer.error;
|
|
621
706
|
const identity = viewer.identity || null;
|
|
622
|
-
const denied = gateVisibility(url, env, obj, identity);
|
|
707
|
+
const denied = await gateVisibility(url, env, obj, identity);
|
|
623
708
|
if (denied) return denied;
|
|
624
709
|
// Token-gated read auth (PHNX-3654): a 'private' page is served only to a
|
|
625
710
|
// request carrying the matching viewer key (?k= or Bearer), or to its owner.
|
|
@@ -718,7 +803,16 @@ export default {
|
|
|
718
803
|
if (delSegments[0] && delSegments[0] === uid) {
|
|
719
804
|
// own leftover UUID prefix
|
|
720
805
|
} else if (delSegments[0] && delSegments[0] === handle) {
|
|
721
|
-
const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner);
|
|
806
|
+
const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
|
|
807
|
+
if (owned.error) return owned.error;
|
|
808
|
+
} else if (delSegments[0]) {
|
|
809
|
+
// An explicitly-chosen handle (the CLI's --handle, PHNX-3547): the
|
|
810
|
+
// caller's derived handle differs from the namespace, so the claim is
|
|
811
|
+
// the authority — assertHandleOwner refuses strangers (409) and
|
|
812
|
+
// recovers a same-email account move. No claim at all is a mismatch.
|
|
813
|
+
const claim = await env.BUCKET.get('__handles/' + delSegments[0]);
|
|
814
|
+
if (!claim) return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
|
|
815
|
+
const owned = await assertHandleOwner(env.BUCKET, delSegments[0], auth.owner, auth.email || '');
|
|
722
816
|
if (owned.error) return owned.error;
|
|
723
817
|
} else {
|
|
724
818
|
return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
|
|
@@ -1335,8 +1429,13 @@ function phoenixHandle(auth) {
|
|
|
1335
1429
|
}
|
|
1336
1430
|
|
|
1337
1431
|
// First writer of a handle owns it. Later PUTs from the same userId are fine;
|
|
1338
|
-
// a different userId whose email local-part collides gets 409, not a silent
|
|
1339
|
-
|
|
1432
|
+
// a different userId whose email local-part collides gets 409, not a silent
|
|
1433
|
+
// overwrite — EXCEPT when the verified Phoenix email matches the claim's
|
|
1434
|
+
// recorded email exactly: that is the same human re-authenticated under a new
|
|
1435
|
+
// userId (an account move), and the claim transfers to them instead of
|
|
1436
|
+
// dead-ending (PHNX-3547). The transfer also re-stamps owner on the old
|
|
1437
|
+
// account's objects so PATCH/DELETE keep working.
|
|
1438
|
+
async function assertHandleOwner(bucket, handle, userId, email) {
|
|
1340
1439
|
// The __handles/<handle> claim object is the authoritative first-writer record
|
|
1341
1440
|
// of ownership. When it exists it decides ownership OUTRIGHT: the recorded
|
|
1342
1441
|
// userId may write, anyone else is refused. Consult it FIRST — a stray page
|
|
@@ -1347,8 +1446,17 @@ async function assertHandleOwner(bucket, handle, userId) {
|
|
|
1347
1446
|
const key = '__handles/' + handle;
|
|
1348
1447
|
const existing = await bucket.get(key);
|
|
1349
1448
|
if (existing) {
|
|
1350
|
-
const
|
|
1449
|
+
const meta = existing.customMetadata || {};
|
|
1450
|
+
const claimed = meta.userId;
|
|
1351
1451
|
if (claimed && claimed !== userId) {
|
|
1452
|
+
// Same verified email, different userId → account move, not a rival:
|
|
1453
|
+
// rebind the claim and migrate the old owner's objects. Legacy claims
|
|
1454
|
+
// written before the claim recorded an email cannot prove this and keep
|
|
1455
|
+
// the permanent 409.
|
|
1456
|
+
if (email && meta.email && String(meta.email).toLowerCase() === String(email).toLowerCase()) {
|
|
1457
|
+
await transferHandle(bucket, handle, claimed, userId, email);
|
|
1458
|
+
return {};
|
|
1459
|
+
}
|
|
1352
1460
|
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
1353
1461
|
}
|
|
1354
1462
|
return {};
|
|
@@ -1369,15 +1477,48 @@ async function assertHandleOwner(bucket, handle, userId) {
|
|
|
1369
1477
|
return {};
|
|
1370
1478
|
}
|
|
1371
1479
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1480
|
+
// Account-move recovery (PHNX-3547): the claim's recorded userId held the handle;
|
|
1481
|
+
// the caller proves the SAME verified email under a NEW userId. Rebind the claim
|
|
1482
|
+
// and re-stamp owner on every object the old userId owned under this prefix, so
|
|
1483
|
+
// the moved account keeps full control of its shares. Objects owned by anyone
|
|
1484
|
+
// else (BYO namespace stamps, a pre-claim stray) are left untouched.
|
|
1485
|
+
async function transferHandle(bucket, handle, oldUserId, newUserId, email) {
|
|
1486
|
+
await bucket.put('__handles/' + handle, JSON.stringify({ userId: newUserId, email: email }), {
|
|
1487
|
+
httpMetadata: { contentType: 'application/json' },
|
|
1488
|
+
customMetadata: { userId: newUserId, email: email, visibility: 'unlisted' },
|
|
1489
|
+
});
|
|
1490
|
+
let cursor;
|
|
1491
|
+
do {
|
|
1492
|
+
const list = await bucket.list({ prefix: handle + '/', cursor: cursor, include: ['customMetadata'] });
|
|
1493
|
+
for (const o of list.objects || []) {
|
|
1494
|
+
const owner = o.customMetadata && o.customMetadata.owner;
|
|
1495
|
+
if (!owner || owner !== oldUserId) continue;
|
|
1496
|
+
const obj = await bucket.get(o.key);
|
|
1497
|
+
if (!obj) continue;
|
|
1498
|
+
const headers = new Headers();
|
|
1499
|
+
if (typeof obj.writeHttpMetadata === 'function') obj.writeHttpMetadata(headers);
|
|
1500
|
+
const customMetadata = { ...(obj.customMetadata || {}), owner: newUserId };
|
|
1501
|
+
await bucket.put(o.key, obj.body, {
|
|
1502
|
+
httpMetadata: headers.get('content-type') ? { contentType: headers.get('content-type') } : undefined,
|
|
1503
|
+
customMetadata: customMetadata,
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
cursor = list.truncated ? list.cursor : undefined;
|
|
1507
|
+
} while (cursor);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
async function claimHandle(bucket, handle, userId, email) {
|
|
1511
|
+
const owned = await assertHandleOwner(bucket, handle, userId, email);
|
|
1374
1512
|
if (owned.error) return owned;
|
|
1375
1513
|
const key = '__handles/' + handle;
|
|
1376
1514
|
// Write (or rewrite) so a same-user republish resets object Age against the
|
|
1377
|
-
// bucket's 366-day lifecycle — otherwise the claim can expire while pages stay
|
|
1378
|
-
|
|
1515
|
+
// bucket's 366-day lifecycle — otherwise the claim can expire while pages stay
|
|
1516
|
+
// live. The claim also records the verified email: it is what lets a future
|
|
1517
|
+
// same-email/different-userId request prove an account move and recover the
|
|
1518
|
+
// handle instead of hitting the permanent 409.
|
|
1519
|
+
await bucket.put(key, JSON.stringify({ userId: userId, email: email }), {
|
|
1379
1520
|
httpMetadata: { contentType: 'application/json' },
|
|
1380
|
-
customMetadata: { userId: userId, visibility: 'unlisted' },
|
|
1521
|
+
customMetadata: { userId: userId, email: email || '', visibility: 'unlisted' },
|
|
1381
1522
|
});
|
|
1382
1523
|
return {};
|
|
1383
1524
|
}
|
|
@@ -1423,16 +1564,36 @@ function managedCoverHeaders(visibility) {
|
|
|
1423
1564
|
// Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
|
|
1424
1565
|
// caller resolves the viewer once (it also needs the identity for the ownership
|
|
1425
1566
|
// check) and both the page GET and the ?revisions=json path share this gate.
|
|
1426
|
-
function gateVisibility(url, env, obj, identity) {
|
|
1567
|
+
async function gateVisibility(url, env, obj, identity) {
|
|
1427
1568
|
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
1428
1569
|
if (!isIdentityGated(visibility)) return null;
|
|
1429
1570
|
if (!identity) return bounceToLogin(url, env);
|
|
1430
1571
|
if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
|
|
1572
|
+
// A 'me' page reads for its stamped owner (the fast path above) OR for the
|
|
1573
|
+
// holder of the namespace's handle claim — the same authority PATCH and
|
|
1574
|
+
// DELETE use. Without this, the claim holder who takes a fleet/BYO-stamped
|
|
1575
|
+
// or pre-stamp page to 'me' (owner = namespace, or none) would be locked
|
|
1576
|
+
// out of her own page: PATCH says 200, GET says 404. The stamp itself stays
|
|
1577
|
+
// untouched (expiry refunds credit the stamped ledger), so the read gate
|
|
1578
|
+
// has to consult the claim rather than the stamp.
|
|
1579
|
+
const handle = decodeURIComponent(url.pathname).split('/').filter(Boolean)[0] || '';
|
|
1580
|
+
if (visibility === 'me' && handle && (await holdsHandleClaim(env.BUCKET, handle, identity.userId))) return null;
|
|
1431
1581
|
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
1432
1582
|
}
|
|
1433
1583
|
return null;
|
|
1434
1584
|
}
|
|
1435
1585
|
|
|
1586
|
+
// True when userId is the recorded holder of the __handles/<handle> claim.
|
|
1587
|
+
// Read-only: never transfers or writes a claim (that is claimHandle /
|
|
1588
|
+
// assertHandleOwner's job on the write paths).
|
|
1589
|
+
async function holdsHandleClaim(bucket, handle, userId) {
|
|
1590
|
+
if (!handle || !userId) return false;
|
|
1591
|
+
const claim = await bucket.get('__handles/' + handle);
|
|
1592
|
+
if (!claim) return false;
|
|
1593
|
+
const claimed = claim.customMetadata && claim.customMetadata.userId;
|
|
1594
|
+
return !!claimed && claimed === userId;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1436
1597
|
// SHA-256 hex of a string — the form a 'private' object's stored
|
|
1437
1598
|
// 'viewer-token-hash' takes. Both the PUT (hash-on-store) and the read gate use
|
|
1438
1599
|
// this, so a token minted by the CLI matches byte-for-byte (PHNX-3654).
|
|
@@ -1909,7 +2070,7 @@ async function renderOgCard(input) {
|
|
|
1909
2070
|
props: {
|
|
1910
2071
|
style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
|
|
1911
2072
|
children: [
|
|
1912
|
-
{ type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: '
|
|
2073
|
+
{ type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'share.getrush.ai' } },
|
|
1913
2074
|
{ type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
|
|
1914
2075
|
{ type: 'div', props: { style: { display: 'flex', fontSize: 66, lineHeight: 1.06, fontWeight: 700, letterSpacing: '-2.8px', maxHeight: 218, overflow: 'hidden' }, children: input.title || 'Shared artifact' } },
|
|
1915
2076
|
input.description ? { type: 'div', props: { style: { display: 'flex', marginTop: 24, color: '#a3a3a3', fontSize: 27, lineHeight: 1.35, maxHeight: 74, overflow: 'hidden' }, children: input.description } } : null,
|
|
@@ -2,9 +2,9 @@ const LOADED_COMMAND_NAMES = [
|
|
|
2
2
|
'accounts', 'auth', 'view', 'inspect', 'feedback', 'commands', 'hooks', 'skills', 'rules', 'memory',
|
|
3
3
|
'permissions', 'mcp', 'clis', 'subagents', 'plugins', 'workflows', 'add', 'use',
|
|
4
4
|
'remove', 'rm', 'purge', 'update', 'prune', 'import', 'registry', 'search', 'install', 'packages',
|
|
5
|
-
'routines', 'monitors', 'projects', 'run', 'open', 'reconnect', 'fork', 'config',
|
|
5
|
+
'routines', 'monitors', 'projects', 'run', '_callback', 'open', 'reconnect', 'fork', 'config',
|
|
6
6
|
'models', 'modes', 'trash', 'restore', 'doctor',
|
|
7
|
-
'route', 'harness', 'harnesses', 'secrets', 'menubar', 'sync',
|
|
7
|
+
'route', 'routes', 'harness', 'harnesses', 'secrets', 'menubar', 'sync',
|
|
8
8
|
'refresh-rules', 'factory', 'insights', 'trace', 'reminders',
|
|
9
9
|
'pty', 'tmux', 'watchdog', 'browser', 'computer', 'logs', 'events',
|
|
10
10
|
'ssh', 'devices', 'fleet', 'repos', 'repo', 'setup', 'uninstall', 'upgrade', 'sessions',
|