@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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/dist/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/object-io.ts
DELETED
|
@@ -1,1306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectIO — transport seam for vault object byte/metadata movement.
|
|
3
|
-
*
|
|
4
|
-
* s3.ts holds the *semantics* of sync (symlink-record encoding, mode/mtime
|
|
5
|
-
* stamping, created-at preservation, directory-marker filtering). Those never
|
|
6
|
-
* change. What CAN change is the *wire transport* underneath them:
|
|
7
|
-
*
|
|
8
|
-
* - `S3SdkObjectIO` — the historical path. STS-vended credentials + the AWS
|
|
9
|
-
* S3 SDK talking directly to the per-company bucket. No policy-size
|
|
10
|
-
* ceiling concern for the BYTES, but the STS session policy that grants
|
|
11
|
-
* access has the 2048-char IAM limit that motivated the presigned model.
|
|
12
|
-
*
|
|
13
|
-
* - `PresignObjectIO` — the presigned-URL path. The vault-service decides
|
|
14
|
-
* access as a runtime DDB check (no IAM policy ceiling) and hands back
|
|
15
|
-
* short-lived presigned GET/PUT/DELETE URLs + (for PUT) the exact headers
|
|
16
|
-
* to replay. The client never holds AWS credentials — it requests the
|
|
17
|
-
* signed URLs directly.
|
|
18
|
-
*
|
|
19
|
-
* The seam is a per-EntityContext factory resolved INSIDE s3.ts, so every
|
|
20
|
-
* existing call site (`uploadFile(ctx, …)`, `downloadFile(ctx, …)`, …) keeps
|
|
21
|
-
* its signature. `runRunner` selects the transport once per session via
|
|
22
|
-
* {@link setObjectIOFactory}; absent any selection the default is the S3 SDK,
|
|
23
|
-
* preserving today's behavior for every non-gated caller.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
S3Client,
|
|
28
|
-
PutObjectCommand,
|
|
29
|
-
GetObjectCommand,
|
|
30
|
-
ListObjectsV2Command,
|
|
31
|
-
DeleteObjectCommand,
|
|
32
|
-
HeadObjectCommand,
|
|
33
|
-
} from "@aws-sdk/client-s3";
|
|
34
|
-
import * as http from "node:http";
|
|
35
|
-
import * as https from "node:https";
|
|
36
|
-
import type { IncomingMessage } from "node:http";
|
|
37
|
-
import type { EntityContext } from "./types.js";
|
|
38
|
-
import type {
|
|
39
|
-
PresignOp,
|
|
40
|
-
PresignKeyInput,
|
|
41
|
-
PresignResultRow,
|
|
42
|
-
VaultListedObject,
|
|
43
|
-
} from "./vault-client.js";
|
|
44
|
-
import { VaultClientError } from "./vault-client.js";
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* The slice of {@link VaultClient} the presigned transport needs. Narrowed to
|
|
48
|
-
* just `presign` + `listFiles` so the factory accepts any caller that exposes
|
|
49
|
-
* those two (the real VaultClient, or a stub in tests) without depending on
|
|
50
|
-
* the full 20-method surface.
|
|
51
|
-
*/
|
|
52
|
-
export interface PresignTransportClient {
|
|
53
|
-
presign(input: {
|
|
54
|
-
companyUid: string;
|
|
55
|
-
op?: PresignOp;
|
|
56
|
-
expiresIn?: number;
|
|
57
|
-
keys: PresignKeyInput[];
|
|
58
|
-
}): Promise<{ results: PresignResultRow[]; expiresAt: string }>;
|
|
59
|
-
listFiles(
|
|
60
|
-
companyUid: string,
|
|
61
|
-
prefix?: string,
|
|
62
|
-
cursor?: string,
|
|
63
|
-
): Promise<{
|
|
64
|
-
objects: VaultListedObject[];
|
|
65
|
-
cursor: string | null;
|
|
66
|
-
truncated: boolean;
|
|
67
|
-
}>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Wire-primitive shapes
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Conditional-write fence for a PUT (S3 conditional writes, GA 2024-11).
|
|
76
|
-
*
|
|
77
|
-
* `ifMatch` — only land the PUT if the remote object's ETag still equals
|
|
78
|
-
* this value (the journal baseline / last-observed HEAD). `ifNoneMatch: "*"`
|
|
79
|
-
* — only land the PUT if NO object exists at the key (creation fence).
|
|
80
|
-
* Either mismatch makes S3 reject with 412 PreconditionFailed, which the
|
|
81
|
-
* push path surfaces as a conflict instead of a silent overwrite.
|
|
82
|
-
*
|
|
83
|
-
* This is the storage-level backstop for the entire stale-clobber class:
|
|
84
|
-
* a HEAD-then-PUT race, a transport bug that misreads remote state, or an
|
|
85
|
-
* outdated client mid-pass can no longer regress a newer remote object —
|
|
86
|
-
* S3 itself refuses. (The 2026-06-10..12 vault regression storm was this
|
|
87
|
-
* class: stale machine copies blind-PUT over newer objects.)
|
|
88
|
-
*/
|
|
89
|
-
export interface PutPrecondition {
|
|
90
|
-
/** Land only if the current remote ETag equals this (quotes optional). */
|
|
91
|
-
ifMatch?: string;
|
|
92
|
-
/** Land only if no object exists at the key. */
|
|
93
|
-
ifNoneMatch?: "*";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export interface PutObjectInput extends PutPrecondition {
|
|
97
|
-
key: string;
|
|
98
|
-
body: Buffer;
|
|
99
|
-
contentType: string;
|
|
100
|
-
/** S3 user metadata (x-amz-meta-*). Lowercased keys by convention. */
|
|
101
|
-
metadata?: Record<string, string>;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface GetObjectResult {
|
|
105
|
-
body: Buffer;
|
|
106
|
-
/** S3 user metadata (keys lowercased by S3). */
|
|
107
|
-
metadata?: Record<string, string>;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface GetObjectStreamResult {
|
|
111
|
-
body: AsyncIterable<Uint8Array>;
|
|
112
|
-
/** S3 user metadata (keys lowercased by S3). */
|
|
113
|
-
metadata?: Record<string, string>;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface ListObjectsInput {
|
|
117
|
-
prefix?: string;
|
|
118
|
-
continuationToken?: string;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface ListedRemoteObject {
|
|
122
|
-
key: string;
|
|
123
|
-
size: number;
|
|
124
|
-
lastModified: Date;
|
|
125
|
-
etag: string;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface ListObjectsResult {
|
|
129
|
-
objects: ListedRemoteObject[];
|
|
130
|
-
/** Opaque cursor for the next page; undefined when the listing is exhausted. */
|
|
131
|
-
nextContinuationToken?: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface HeadObjectResult {
|
|
135
|
-
lastModified: Date;
|
|
136
|
-
etag: string;
|
|
137
|
-
size: number;
|
|
138
|
-
metadata?: Record<string, string>;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* The minimal byte/metadata transport s3.ts needs. Deliberately narrow — no
|
|
143
|
-
* symlink, mode, or created-at concepts leak in here; those live one layer up
|
|
144
|
-
* in s3.ts and compose on top of these five primitives.
|
|
145
|
-
*/
|
|
146
|
-
export interface ObjectIO {
|
|
147
|
-
putObject(input: PutObjectInput): Promise<{ etag: string }>;
|
|
148
|
-
getObject(key: string): Promise<GetObjectResult>;
|
|
149
|
-
getObjectStream?(key: string): Promise<GetObjectStreamResult>;
|
|
150
|
-
listObjects(input: ListObjectsInput): Promise<ListObjectsResult>;
|
|
151
|
-
deleteObject(key: string): Promise<void>;
|
|
152
|
-
/**
|
|
153
|
-
* Null ONLY when the key definitively does not exist (404). Access denial
|
|
154
|
-
* (403 / per-key presign denial) THROWS a `name: "Forbidden"` error — it is
|
|
155
|
-
* unknown state, never "absent". Conflating the two disables push-side
|
|
156
|
-
* conflict guards and clobbers newer remote objects.
|
|
157
|
-
*/
|
|
158
|
-
headObject(key: string): Promise<HeadObjectResult | null>;
|
|
159
|
-
/**
|
|
160
|
-
* Optional batch pre-mint. Warms an internal URL cache for `keys` under `op`
|
|
161
|
-
* so subsequent per-key get/head (and, when primed, put/delete) calls reuse a
|
|
162
|
-
* pre-signed URL instead of issuing one presign request each. This is what
|
|
163
|
-
* turns an N-file sync from N presign calls into ceil(N/chunk) — the
|
|
164
|
-
* difference between staying under and blowing past the 100-req/hr limit on a
|
|
165
|
-
* bulk pull. The S3 SDK transport has no presign step and omits this (the
|
|
166
|
-
* per-call cost there is the SDK request itself, not a separate presign).
|
|
167
|
-
* Best-effort: a failed chunk or per-key denial simply leaves those keys
|
|
168
|
-
* uncached, and the per-key call falls back to a single presign.
|
|
169
|
-
*/
|
|
170
|
-
prime?(op: PresignOp, keys: PresignKeyInput[]): Promise<void>;
|
|
171
|
-
/**
|
|
172
|
-
* True if a live primed PUT URL exists for `key`. Lets uploadFile/uploadSymlink
|
|
173
|
-
* skip recomputing metadata + the created-at HEAD when a `prime("put", …)`
|
|
174
|
-
* pre-pass already signed the metadata into the cached URL (the upload just
|
|
175
|
-
* sends the body, replaying the cached headers). Absent (undefined) on the S3
|
|
176
|
-
* SDK transport → callers take their normal compute-metadata path.
|
|
177
|
-
*/
|
|
178
|
-
hasPrimedPut?(key: string): boolean;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
// S3 SDK transport (default)
|
|
183
|
-
// ---------------------------------------------------------------------------
|
|
184
|
-
|
|
185
|
-
function stripQuotes(etag: string | undefined): string {
|
|
186
|
-
return etag ? etag.replace(/^"|"$/g, "") : "";
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function drainToBuffer(
|
|
190
|
-
body: AsyncIterable<Uint8Array> | undefined,
|
|
191
|
-
): Promise<Buffer> {
|
|
192
|
-
if (!body) return Buffer.alloc(0);
|
|
193
|
-
const chunks: Buffer[] = [];
|
|
194
|
-
for await (const chunk of body) {
|
|
195
|
-
chunks.push(Buffer.from(chunk));
|
|
196
|
-
}
|
|
197
|
-
return Buffer.concat(chunks);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Direct-to-S3 transport over STS-vended credentials. A fresh client per
|
|
202
|
-
* instance so the latest credentials from the EntityContext are always used
|
|
203
|
-
* (caching/refresh is the caller's concern, at the EntityContext level — see
|
|
204
|
-
* the original buildClient note in s3.ts).
|
|
205
|
-
*/
|
|
206
|
-
export class S3SdkObjectIO implements ObjectIO {
|
|
207
|
-
private readonly client: S3Client;
|
|
208
|
-
private readonly bucket: string;
|
|
209
|
-
|
|
210
|
-
constructor(ctx: EntityContext) {
|
|
211
|
-
this.bucket = ctx.bucketName;
|
|
212
|
-
if (!ctx.credentials) {
|
|
213
|
-
// A credential-less context only exists on the presign path (HQ-59
|
|
214
|
-
// company vaults skip the STS vend). Such a context must route through
|
|
215
|
-
// PresignObjectIO, never here — reaching the direct-S3 transport with no
|
|
216
|
-
// creds is a routing bug, so fail loudly instead of building a broken
|
|
217
|
-
// S3 client that would later 403 with an opaque AWS error.
|
|
218
|
-
throw new Error(
|
|
219
|
-
`S3SdkObjectIO requires STS credentials but got a presign-only ` +
|
|
220
|
-
`context for ${ctx.uid}; company presign contexts must use ` +
|
|
221
|
-
`PresignObjectIO. This is a transport-routing bug.`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
this.client = new S3Client({
|
|
225
|
-
region: ctx.region,
|
|
226
|
-
credentials: {
|
|
227
|
-
accessKeyId: ctx.credentials.accessKeyId,
|
|
228
|
-
secretAccessKey: ctx.credentials.secretAccessKey,
|
|
229
|
-
sessionToken: ctx.credentials.sessionToken,
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async putObject(input: PutObjectInput): Promise<{ etag: string }> {
|
|
235
|
-
const res = await this.client.send(
|
|
236
|
-
new PutObjectCommand({
|
|
237
|
-
Bucket: this.bucket,
|
|
238
|
-
Key: input.key,
|
|
239
|
-
Body: input.body,
|
|
240
|
-
ContentType: input.contentType,
|
|
241
|
-
...(input.metadata && Object.keys(input.metadata).length > 0
|
|
242
|
-
? { Metadata: input.metadata }
|
|
243
|
-
: {}),
|
|
244
|
-
// Conditional-write fence. If-Match wants the quoted entity-tag form;
|
|
245
|
-
// callers hand us journal/HEAD etags that may be stripped — re-quote
|
|
246
|
-
// so both shapes fence identically. A mismatch surfaces as the SDK's
|
|
247
|
-
// name:"PreconditionFailed" (HTTP 412), which the push path maps to
|
|
248
|
-
// its conflict flow.
|
|
249
|
-
...(input.ifMatch ? { IfMatch: quoteEtag(input.ifMatch) } : {}),
|
|
250
|
-
...(input.ifNoneMatch ? { IfNoneMatch: input.ifNoneMatch } : {}),
|
|
251
|
-
}),
|
|
252
|
-
);
|
|
253
|
-
return { etag: res.ETag || "" };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async getObjectStream(key: string): Promise<GetObjectStreamResult> {
|
|
257
|
-
const res = await this.client.send(
|
|
258
|
-
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
259
|
-
);
|
|
260
|
-
if (!res.Body) {
|
|
261
|
-
throw new Error(`Empty response for ${key}`);
|
|
262
|
-
}
|
|
263
|
-
return {
|
|
264
|
-
body: res.Body as AsyncIterable<Uint8Array>,
|
|
265
|
-
metadata: res.Metadata,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async getObject(key: string): Promise<GetObjectResult> {
|
|
270
|
-
const res = await this.getObjectStream(key);
|
|
271
|
-
const body = await drainToBuffer(res.body);
|
|
272
|
-
return { body, metadata: res.metadata };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async listObjects(input: ListObjectsInput): Promise<ListObjectsResult> {
|
|
276
|
-
const res = await this.client.send(
|
|
277
|
-
new ListObjectsV2Command({
|
|
278
|
-
Bucket: this.bucket,
|
|
279
|
-
Prefix: input.prefix,
|
|
280
|
-
ContinuationToken: input.continuationToken,
|
|
281
|
-
}),
|
|
282
|
-
);
|
|
283
|
-
const objects: ListedRemoteObject[] = [];
|
|
284
|
-
for (const obj of res.Contents || []) {
|
|
285
|
-
if (!obj.Key) continue;
|
|
286
|
-
objects.push({
|
|
287
|
-
key: obj.Key,
|
|
288
|
-
size: obj.Size ?? 0,
|
|
289
|
-
lastModified: obj.LastModified || new Date(),
|
|
290
|
-
etag: obj.ETag || "",
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
return {
|
|
294
|
-
objects,
|
|
295
|
-
nextContinuationToken: res.NextContinuationToken,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
async deleteObject(key: string): Promise<void> {
|
|
300
|
-
await this.client.send(
|
|
301
|
-
new DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async headObject(key: string): Promise<HeadObjectResult | null> {
|
|
306
|
-
try {
|
|
307
|
-
const res = await this.client.send(
|
|
308
|
-
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
309
|
-
);
|
|
310
|
-
return {
|
|
311
|
-
lastModified: res.LastModified || new Date(),
|
|
312
|
-
etag: res.ETag || "",
|
|
313
|
-
size: res.ContentLength || 0,
|
|
314
|
-
metadata: res.Metadata,
|
|
315
|
-
};
|
|
316
|
-
} catch (err: unknown) {
|
|
317
|
-
if (
|
|
318
|
-
err &&
|
|
319
|
-
typeof err === "object" &&
|
|
320
|
-
"name" in err &&
|
|
321
|
-
(err as { name?: string }).name === "NotFound"
|
|
322
|
-
) {
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
325
|
-
throw err;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ---------------------------------------------------------------------------
|
|
331
|
-
// Presigned-URL transport
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Pull every `x-amz-meta-*` response header into a plain metadata record with
|
|
336
|
-
* the prefix stripped. S3 surfaces user metadata this way on GET/HEAD; fetch
|
|
337
|
-
* lowercases header names, matching how the SDK lowercases `Metadata` keys, so
|
|
338
|
-
* the read path in s3.ts is identical across both transports.
|
|
339
|
-
*/
|
|
340
|
-
function metaFromHeaders(headers: Headers): Record<string, string> {
|
|
341
|
-
const meta: Record<string, string> = {};
|
|
342
|
-
headers.forEach((value, name) => {
|
|
343
|
-
if (name.startsWith("x-amz-meta-")) {
|
|
344
|
-
meta[name.slice("x-amz-meta-".length)] = value;
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
return meta;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Response shape shared by every presigned GET consumer. Unlike the runtime
|
|
352
|
-
* `fetch` response, its body is backed by Node's core HTTP client, so it never
|
|
353
|
-
* enters the runtime-bundled undici parser path.
|
|
354
|
-
*/
|
|
355
|
-
export interface PresignedGetResponse {
|
|
356
|
-
status: number;
|
|
357
|
-
headers: Headers;
|
|
358
|
-
body?: AsyncIterable<Uint8Array>;
|
|
359
|
-
destroy(): void;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** Transport seam retained for deterministic wire-level regression tests. */
|
|
363
|
-
export interface PresignedGetTransport {
|
|
364
|
-
get(
|
|
365
|
-
url: string,
|
|
366
|
-
headers: Record<string, string> | undefined,
|
|
367
|
-
): Promise<PresignedGetResponse>;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function headersFromNode(
|
|
371
|
-
headers: IncomingMessage["headers"],
|
|
372
|
-
): Headers {
|
|
373
|
-
const out = new Headers();
|
|
374
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
375
|
-
if (value === undefined) continue;
|
|
376
|
-
out.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
377
|
-
}
|
|
378
|
-
return out;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function expectedContentLength(headers: Headers): number | undefined {
|
|
382
|
-
const value = headers.get("content-length");
|
|
383
|
-
if (value === null) return undefined;
|
|
384
|
-
if (!/^\d+$/.test(value)) {
|
|
385
|
-
throw new Error(`invalid presigned GET content-length: ${value}`);
|
|
386
|
-
}
|
|
387
|
-
const length = Number(value);
|
|
388
|
-
if (!Number.isSafeInteger(length)) {
|
|
389
|
-
throw new Error(`invalid presigned GET content-length: ${value}`);
|
|
390
|
-
}
|
|
391
|
-
return length;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function contentLengthError(expected: number, actual: number): Error {
|
|
395
|
-
return new Error(
|
|
396
|
-
`presigned GET content-length mismatch: expected ${expected} bytes, received ${actual}`,
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
async function* checkedNodeBody(
|
|
401
|
-
message: IncomingMessage,
|
|
402
|
-
expected: number | undefined,
|
|
403
|
-
): AsyncIterable<Uint8Array> {
|
|
404
|
-
let received = 0;
|
|
405
|
-
let exhausted = false;
|
|
406
|
-
try {
|
|
407
|
-
try {
|
|
408
|
-
for await (const chunk of message) {
|
|
409
|
-
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
410
|
-
received += bytes.byteLength;
|
|
411
|
-
if (expected !== undefined && received > expected) {
|
|
412
|
-
throw contentLengthError(expected, received);
|
|
413
|
-
}
|
|
414
|
-
yield bytes;
|
|
415
|
-
}
|
|
416
|
-
} catch (err) {
|
|
417
|
-
// A FIN before Node has received the declared bytes normally surfaces as
|
|
418
|
-
// an "aborted" stream error. Preserve the useful data-integrity cause.
|
|
419
|
-
if (expected !== undefined && received !== expected) {
|
|
420
|
-
throw contentLengthError(expected, received);
|
|
421
|
-
}
|
|
422
|
-
throw err;
|
|
423
|
-
}
|
|
424
|
-
exhausted = true;
|
|
425
|
-
if (expected !== undefined && received !== expected) {
|
|
426
|
-
throw contentLengthError(expected, received);
|
|
427
|
-
}
|
|
428
|
-
} finally {
|
|
429
|
-
// Consumers such as HEAD only inspect headers, and a caller can stop after
|
|
430
|
-
// a prefix. Destroying in both cases releases the socket immediately.
|
|
431
|
-
if (!exhausted) message.destroy();
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function nodePresignedGet(
|
|
436
|
-
url: string,
|
|
437
|
-
headers: Record<string, string> | undefined,
|
|
438
|
-
): Promise<PresignedGetResponse> {
|
|
439
|
-
const target = new URL(url);
|
|
440
|
-
return new Promise((resolve, reject) => {
|
|
441
|
-
const handleResponse = (message: IncomingMessage): void => {
|
|
442
|
-
const responseHeaders = headersFromNode(message.headers);
|
|
443
|
-
let expected: number | undefined;
|
|
444
|
-
try {
|
|
445
|
-
expected = expectedContentLength(responseHeaders);
|
|
446
|
-
} catch (err) {
|
|
447
|
-
message.destroy();
|
|
448
|
-
reject(err);
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
resolve({
|
|
452
|
-
status: message.statusCode ?? 0,
|
|
453
|
-
headers: responseHeaders,
|
|
454
|
-
body: checkedNodeBody(message, expected),
|
|
455
|
-
destroy: () => message.destroy(),
|
|
456
|
-
});
|
|
457
|
-
};
|
|
458
|
-
const request = target.protocol === "https:"
|
|
459
|
-
? https.request(target, { method: "GET", headers }, handleResponse)
|
|
460
|
-
: target.protocol === "http:"
|
|
461
|
-
? http.request(target, { method: "GET", headers }, handleResponse)
|
|
462
|
-
: undefined;
|
|
463
|
-
if (!request) {
|
|
464
|
-
reject(new Error(`unsupported presigned GET protocol: ${target.protocol}`));
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
request.once("error", reject);
|
|
468
|
-
request.end();
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
const NODE_PRESIGNED_GET_TRANSPORT: PresignedGetTransport = {
|
|
473
|
-
get: nodePresignedGet,
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
let presignedGetTransport: PresignedGetTransport = NODE_PRESIGNED_GET_TRANSPORT;
|
|
477
|
-
|
|
478
|
-
/** Test-only override; production always uses Node's core HTTP(S) transport. */
|
|
479
|
-
export function setPresignedGetTransportForTesting(
|
|
480
|
-
transport: PresignedGetTransport | null,
|
|
481
|
-
): void {
|
|
482
|
-
presignedGetTransport = transport ?? NODE_PRESIGNED_GET_TRANSPORT;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Per-key presign denial codes that are EXPECTED and must be reclassified as
|
|
487
|
-
* skip-with-log — throwing the `name: "Forbidden"` shape (see
|
|
488
|
-
* {@link accessDeniedError}) so `isAccessDenied` in sync.ts / share.ts routes
|
|
489
|
-
* them into the silent per-key scope-excluded skip, never a FATAL sync error.
|
|
490
|
-
*
|
|
491
|
-
* Two families qualify:
|
|
492
|
-
* 1. Any `*_FORBIDDEN` code — an authorization/ACL denial. Covers
|
|
493
|
-
* FILES_PRESIGN_FORBIDDEN (no write on a shared prefix), FILES_RAW_FORBIDDEN,
|
|
494
|
-
* and — the structural sessions-ACL rejection this US-006 phase relies on —
|
|
495
|
-
* FILES_PRESIGN_SESSIONS_FORBIDDEN (a spoofed/foreign `sessions/{uid}/`
|
|
496
|
-
* prefix the server refuses to presign).
|
|
497
|
-
* 2. FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED — the US-005 transcript-consent
|
|
498
|
-
* denial. It ends `_REQUIRED`, not `_FORBIDDEN`, so the suffix test alone
|
|
499
|
-
* would MISS it and let it fall through to the fatal generic Error below,
|
|
500
|
-
* re-introducing exactly the recurring partial-failure that poisons every
|
|
501
|
-
* subsequent sync run (US-006 AC5).
|
|
502
|
-
*/
|
|
503
|
-
const SKIP_WITH_LOG_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
504
|
-
"FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
|
|
505
|
-
]);
|
|
506
|
-
|
|
507
|
-
function isSkipWithLogDenialCode(code: unknown): code is string {
|
|
508
|
-
return (
|
|
509
|
-
typeof code === "string" &&
|
|
510
|
-
(code.endsWith("_FORBIDDEN") || SKIP_WITH_LOG_DENIAL_CODES.has(code))
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function firstRowOrThrow(
|
|
515
|
-
results: PresignResultRow[],
|
|
516
|
-
key: string,
|
|
517
|
-
op: string,
|
|
518
|
-
): PresignResultRow {
|
|
519
|
-
const row = results[0];
|
|
520
|
-
if (!row) {
|
|
521
|
-
throw new Error(`presign ${op} returned no row for ${key}`);
|
|
522
|
-
}
|
|
523
|
-
if (row.error || !row.url) {
|
|
524
|
-
// An expected per-key denial (see {@link isSkipWithLogDenialCode}) must
|
|
525
|
-
// throw the SAME `name: "Forbidden"` shape the GET/HEAD path uses so it
|
|
526
|
-
// routes through the `isAccessDenied` skip in sync.ts / share.ts. Without
|
|
527
|
-
// this, a denied presign fell to the generic Error below — `isAccessDenied`
|
|
528
|
-
// returned false — and the per-file ACL skip the GET path handles gracefully
|
|
529
|
-
// became a FATAL sync error: the crash-loop + Sentry flood behind the
|
|
530
|
-
// widespread "presign put denied" incident (members pushing net-new keys to
|
|
531
|
-
// a company-wide-read prefix under private-by-default), and — for the
|
|
532
|
-
// sessions/consent codes — a recurring partial-failure that poisoned every
|
|
533
|
-
// subsequent sync run (US-006 AC5). The denial is expected; only the fatal
|
|
534
|
-
// handling of it was the bug. Log a one-line skip so the reason is visible
|
|
535
|
-
// without the run failing.
|
|
536
|
-
if (isSkipWithLogDenialCode(row.code)) {
|
|
537
|
-
// Shared presign layer: this fires for sync legs AND for non-sync callers
|
|
538
|
-
// (hq files get, signals/sources internals) where the denial surfaces as a
|
|
539
|
-
// user-facing error, not a continued sync. State only what THIS layer did
|
|
540
|
-
// (denied → skipped this row) and let the caller own any "sync continues"
|
|
541
|
-
// / error framing — don't assert an outcome the caller may not take.
|
|
542
|
-
console.warn(
|
|
543
|
-
`presign ${op} denied for '${key}' (${row.code}) — skipped`,
|
|
544
|
-
);
|
|
545
|
-
throw accessDeniedError(
|
|
546
|
-
key,
|
|
547
|
-
`no ${op} access to '${key}' (server ${op} denied: ${row.code})`,
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
throw new Error(
|
|
551
|
-
`presign ${op} denied for ${key}: ${row.error ?? "no url"}${
|
|
552
|
-
row.code ? ` (${row.code})` : ""
|
|
553
|
-
}`,
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
return row;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* An error shaped like the AWS SDK's NoSuchKey/NotFound so existing catch
|
|
561
|
-
* sites in s3.ts (which test `err.name === "NotFound"`) treat a presigned
|
|
562
|
-
* 404 the same as an SDK 404.
|
|
563
|
-
*/
|
|
564
|
-
function notFoundError(key: string): Error {
|
|
565
|
-
return Object.assign(new Error(`Not found: ${key}`), { name: "NotFound" });
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* An error shaped like the AWS SDK's HeadObject 403 (`name: "Forbidden"`) so
|
|
570
|
-
* presigned-transport denials route through the SAME catch sites as SDK ones
|
|
571
|
-
* (share.ts / sync.ts `isAccessDenied`: name === "AccessDenied" | "Forbidden").
|
|
572
|
-
* Critically this is NOT `null`: "can't read the key" must never be conflated
|
|
573
|
-
* with "the key does not exist" — that conflation let a transient 403 episode
|
|
574
|
-
* disable every push-side conflict guard and clobber newer remote objects.
|
|
575
|
-
*/
|
|
576
|
-
function accessDeniedError(key: string, detail: string): Error {
|
|
577
|
-
return Object.assign(
|
|
578
|
-
new Error(`Access denied for ${key}: ${detail}`),
|
|
579
|
-
{ name: "Forbidden" },
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* An error shaped like the AWS SDK's 412 (`name: "PreconditionFailed"`) so
|
|
585
|
-
* presigned-transport conditional-write rejections route through the same
|
|
586
|
-
* catch sites as SDK ones. A 412 means the fence WORKED: the remote moved
|
|
587
|
-
* past the caller's baseline (If-Match) or the key already exists
|
|
588
|
-
* (If-None-Match) — surface as a conflict, never overwrite.
|
|
589
|
-
*/
|
|
590
|
-
function preconditionFailedError(key: string, detail: string): Error {
|
|
591
|
-
return Object.assign(
|
|
592
|
-
new Error(`Precondition failed for ${key}: ${detail}`),
|
|
593
|
-
{ name: "PreconditionFailed" },
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* If-Match compares quoted entity-tags. Journal baselines store etags
|
|
599
|
-
* stripped (normalizeEtag) while SDK HEADs return them quoted — accept both
|
|
600
|
-
* and emit the canonical quoted form.
|
|
601
|
-
*/
|
|
602
|
-
function quoteEtag(etag: string): string {
|
|
603
|
-
return etag.startsWith('"') ? etag : `"${etag}"`;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* Max keys per presign request when priming — the server's hard batch cap
|
|
608
|
-
* (hq-pro files-presign MAX_BATCH_KEYS = 1000). One presign call costs ONE
|
|
609
|
-
* audit row toward the 100/hr limit regardless of how many keys it carries, so
|
|
610
|
-
* filling the batch is strictly better: a 5.5k-file pull is 6 presign calls at
|
|
611
|
-
* 1000/chunk vs 55 at 100. (The sibling list page size is fixed at 1000 by
|
|
612
|
-
* AWS's ListObjectsV2 MaxKeys cap — that one we can't raise.)
|
|
613
|
-
*/
|
|
614
|
-
const PRIME_CHUNK = 1000;
|
|
615
|
-
/**
|
|
616
|
-
* Lifetime requested for primed URLs. Generous (30 min) so a whole sync batch
|
|
617
|
-
* completes within one prime, but well inside the presign Lambda role's
|
|
618
|
-
* credential lifetime (a presigned URL cannot outlive the creds that signed
|
|
619
|
-
* it). A batch that somehow outruns this falls back to per-key single presign.
|
|
620
|
-
*/
|
|
621
|
-
const PRIME_EXPIRES_IN_SECONDS = 1800;
|
|
622
|
-
/** Concurrent prime chunks in flight (each is one presign HTTP call). */
|
|
623
|
-
const PRIME_CONCURRENCY = 4;
|
|
624
|
-
/** Treat a cached URL within this window of expiry as a miss (re-presign). */
|
|
625
|
-
const CACHE_SAFETY_MS = 60_000;
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Opt-in gate for strict fail-closed enforcement of fenced presigned PUTs.
|
|
629
|
-
*
|
|
630
|
-
* Defaults OFF until the hq-pro files-presign server signs and echoes
|
|
631
|
-
* If-Match/If-None-Match. While disabled, the presigned transport preserves
|
|
632
|
-
* today's behavior: replay whatever signed headers the server returned.
|
|
633
|
-
*/
|
|
634
|
-
export const PRESIGN_FENCE_STRICT_ENV_VAR = "HQ_PRESIGN_FENCE_STRICT";
|
|
635
|
-
|
|
636
|
-
function isPresignFenceStrictEnabled(): boolean {
|
|
637
|
-
return process.env[PRESIGN_FENCE_STRICT_ENV_VAR] === "true";
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
interface CacheEntry {
|
|
641
|
-
url: string;
|
|
642
|
-
headers?: Record<string, string>;
|
|
643
|
-
expiresAtMs: number;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
/**
|
|
647
|
-
* Thrown when a presign mint is skipped because the per-user 100/hr vault rate
|
|
648
|
-
* budget is exhausted. Distinct name so callers can tell "deferred, retry next
|
|
649
|
-
* sync" apart from a real transfer failure. The key was NOT synced.
|
|
650
|
-
*/
|
|
651
|
-
export class RateLimitedError extends Error {
|
|
652
|
-
constructor(
|
|
653
|
-
readonly key: string,
|
|
654
|
-
readonly op: PresignOp,
|
|
655
|
-
options?: { cause?: unknown },
|
|
656
|
-
) {
|
|
657
|
-
super(`rate limited (100/hr) — ${op} ${key} deferred to next sync`);
|
|
658
|
-
this.name = "RateLimited";
|
|
659
|
-
if (options?.cause !== undefined) {
|
|
660
|
-
(this as { cause?: unknown }).cause = options.cause;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* A fenced presigned PUT is only safe if the presign service signed and echoed
|
|
667
|
-
* the requested conditional header for replay. Missing/mismatched condition
|
|
668
|
-
* headers mean an older server or a stale primed URL would write
|
|
669
|
-
* unconditionally, so fail closed and let the caller retry later.
|
|
670
|
-
*/
|
|
671
|
-
export class PresignPreconditionMissingError extends Error {
|
|
672
|
-
readonly retryable = true;
|
|
673
|
-
|
|
674
|
-
constructor(
|
|
675
|
-
readonly key: string,
|
|
676
|
-
readonly header: "if-match" | "if-none-match",
|
|
677
|
-
) {
|
|
678
|
-
super(`presigned PUT for ${key} missing required ${header} precondition`);
|
|
679
|
-
this.name = "PresignPreconditionMissing";
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* One-way circuit breaker shared across a run's per-company transports. The
|
|
685
|
-
* first 429 (vault rate budget exhausted) trips it; thereafter every UNCACHED
|
|
686
|
-
* presign fails fast with {@link RateLimitedError} instead of hitting the wire.
|
|
687
|
-
*
|
|
688
|
-
* Without this, an exhausted budget spirals: prime chunks 429 → keys uncached
|
|
689
|
-
* → per-file presign → each 429s (after VaultClient's own 3 retries +
|
|
690
|
-
* backoff) → an 86-minute storm of ~10k doomed calls (observed live). Tripping
|
|
691
|
-
* once and short-circuiting turns that into a clean fast finish: primed URLs
|
|
692
|
-
* still work, un-primed keys are deferred, and the run reports them so the next
|
|
693
|
-
* sync (after the rolling hour recovers) picks them up.
|
|
694
|
-
*/
|
|
695
|
-
export class RateLimitBreaker {
|
|
696
|
-
private tripped = false;
|
|
697
|
-
isTripped(): boolean {
|
|
698
|
-
return this.tripped;
|
|
699
|
-
}
|
|
700
|
-
trip(): void {
|
|
701
|
-
this.tripped = true;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/** A VaultClient 429 (rate budget exhausted) after its own retries. */
|
|
706
|
-
function isRateLimit(err: unknown): boolean {
|
|
707
|
-
return err instanceof VaultClientError && err.statusCode === 429;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Transport that moves bytes over short-lived presigned URLs minted by the
|
|
712
|
-
* vault-service. Holds no AWS credentials. `companyUid` is the EntityContext's
|
|
713
|
-
* `uid` — the server resolves the per-company bucket from it, so cross-company
|
|
714
|
-
* reach is structurally impossible (same authority model as the list/presign
|
|
715
|
-
* handlers).
|
|
716
|
-
*
|
|
717
|
-
* URL cache: {@link prime} batch-mints URLs into `urlCache` (keyed by op+key)
|
|
718
|
-
* so the per-file get/head calls during a sync reuse them instead of issuing a
|
|
719
|
-
* presign request each. A single instance is shared across all s3.ts calls for
|
|
720
|
-
* one company within a run (see {@link presignObjectIOFactory} memoization), so
|
|
721
|
-
* a prime before the transfer loop warms the cache the loop then drains.
|
|
722
|
-
*/
|
|
723
|
-
export class PresignObjectIO implements ObjectIO {
|
|
724
|
-
private readonly urlCache = new Map<string, CacheEntry>();
|
|
725
|
-
private readonly headReuseCache = new Map<string, CacheEntry>();
|
|
726
|
-
|
|
727
|
-
constructor(
|
|
728
|
-
private readonly vault: PresignTransportClient,
|
|
729
|
-
private readonly companyUid: string,
|
|
730
|
-
// Shared across a run's per-company instances by the factory; a directly
|
|
731
|
-
// constructed instance gets its own (fine for tests / one-offs).
|
|
732
|
-
private readonly breaker: RateLimitBreaker = new RateLimitBreaker(),
|
|
733
|
-
) {}
|
|
734
|
-
|
|
735
|
-
private cacheKey(op: PresignOp, key: string): string {
|
|
736
|
-
return `${op}\u0000${key}`;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
hasPrimedPut(key: string): boolean {
|
|
740
|
-
return this.peekCached("put", key) !== undefined;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/** A live (non-expiring) cached URL for op+key, or undefined. */
|
|
744
|
-
private peekCached(op: PresignOp, key: string): CacheEntry | undefined {
|
|
745
|
-
const cacheKey = this.cacheKey(op, key);
|
|
746
|
-
const hit = this.urlCache.get(cacheKey);
|
|
747
|
-
if (!hit) return undefined;
|
|
748
|
-
if (Date.now() >= hit.expiresAtMs - CACHE_SAFETY_MS) {
|
|
749
|
-
this.urlCache.delete(cacheKey);
|
|
750
|
-
return undefined;
|
|
751
|
-
}
|
|
752
|
-
return hit;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/** Return and evict a live cached URL so primed batches are bounded. */
|
|
756
|
-
private consumeCached(op: PresignOp, key: string): CacheEntry | undefined {
|
|
757
|
-
const hit = this.peekCached(op, key);
|
|
758
|
-
if (hit) this.urlCache.delete(this.cacheKey(op, key));
|
|
759
|
-
return hit;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
private peekHeadReuse(key: string): CacheEntry | undefined {
|
|
763
|
-
const hit = this.headReuseCache.get(key);
|
|
764
|
-
if (!hit) return undefined;
|
|
765
|
-
if (Date.now() >= hit.expiresAtMs - CACHE_SAFETY_MS) {
|
|
766
|
-
this.headReuseCache.delete(key);
|
|
767
|
-
return undefined;
|
|
768
|
-
}
|
|
769
|
-
return hit;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
private consumeHeadReuse(key: string): CacheEntry | undefined {
|
|
773
|
-
const hit = this.peekHeadReuse(key);
|
|
774
|
-
if (hit) this.headReuseCache.delete(key);
|
|
775
|
-
return hit;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
private async presignSingle(
|
|
779
|
-
op: PresignOp,
|
|
780
|
-
key: string,
|
|
781
|
-
extra?: {
|
|
782
|
-
contentType?: string;
|
|
783
|
-
metadata?: Record<string, string>;
|
|
784
|
-
/** Conditional-write fence for PUT presigns — see PutPrecondition. */
|
|
785
|
-
ifMatch?: string;
|
|
786
|
-
ifNoneMatch?: "*";
|
|
787
|
-
},
|
|
788
|
-
): Promise<{ url: string; headers?: Record<string, string> }> {
|
|
789
|
-
if (this.breaker.isTripped()) throw new RateLimitedError(key, op);
|
|
790
|
-
let results;
|
|
791
|
-
try {
|
|
792
|
-
({ results } = await this.vault.presign({
|
|
793
|
-
companyUid: this.companyUid,
|
|
794
|
-
op,
|
|
795
|
-
keys: [{ key, op, ...extra }],
|
|
796
|
-
}));
|
|
797
|
-
} catch (err) {
|
|
798
|
-
if (isRateLimit(err)) {
|
|
799
|
-
this.breaker.trip();
|
|
800
|
-
throw new RateLimitedError(key, op, { cause: err });
|
|
801
|
-
}
|
|
802
|
-
throw err;
|
|
803
|
-
}
|
|
804
|
-
const row = firstRowOrThrow(results, key, op);
|
|
805
|
-
return { url: row.url!, headers: row.headers };
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
private async resolveGetUrlForBody(
|
|
809
|
-
key: string,
|
|
810
|
-
): Promise<{ url: string; headers?: Record<string, string> }> {
|
|
811
|
-
const hit = this.consumeCached("get", key);
|
|
812
|
-
if (hit) {
|
|
813
|
-
this.headReuseCache.set(key, hit);
|
|
814
|
-
return { url: hit.url, headers: hit.headers };
|
|
815
|
-
}
|
|
816
|
-
this.headReuseCache.delete(key);
|
|
817
|
-
return this.presignSingle("get", key);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* Resolve a presigned URL (+ replay headers) for op+key: cache hit if primed,
|
|
822
|
-
* else a single presign. Throws on per-key denial (matches the SDK path's
|
|
823
|
-
* access error). `extra` carries PUT contentType/metadata on the miss path.
|
|
824
|
-
*/
|
|
825
|
-
private async resolveUrl(
|
|
826
|
-
op: PresignOp,
|
|
827
|
-
key: string,
|
|
828
|
-
extra?: {
|
|
829
|
-
contentType?: string;
|
|
830
|
-
metadata?: Record<string, string>;
|
|
831
|
-
/** Conditional-write fence for PUT presigns — see PutPrecondition. */
|
|
832
|
-
ifMatch?: string;
|
|
833
|
-
ifNoneMatch?: "*";
|
|
834
|
-
},
|
|
835
|
-
): Promise<{ url: string; headers?: Record<string, string> }> {
|
|
836
|
-
const hasPutFence =
|
|
837
|
-
op === "put" &&
|
|
838
|
-
(extra?.ifMatch !== undefined || extra?.ifNoneMatch !== undefined);
|
|
839
|
-
if (hasPutFence) {
|
|
840
|
-
this.urlCache.delete(this.cacheKey("put", key));
|
|
841
|
-
}
|
|
842
|
-
const hit = hasPutFence ? undefined : this.consumeCached(op, key);
|
|
843
|
-
if (hit) return { url: hit.url, headers: hit.headers };
|
|
844
|
-
// Primed URLs still serve once the breaker trips (no wire needed); only an
|
|
845
|
-
// uncached key on an exhausted budget fails fast.
|
|
846
|
-
return this.presignSingle(op, key, extra);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
private requireSignedPutPreconditions(
|
|
850
|
-
key: string,
|
|
851
|
-
input: PutPrecondition,
|
|
852
|
-
headers: Record<string, string> | undefined,
|
|
853
|
-
): void {
|
|
854
|
-
const lowerHeaders = new Map<string, string>();
|
|
855
|
-
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
856
|
-
lowerHeaders.set(name.toLowerCase(), value);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
if (input.ifMatch) {
|
|
860
|
-
const signed = lowerHeaders.get("if-match");
|
|
861
|
-
if (signed !== quoteEtag(input.ifMatch)) {
|
|
862
|
-
throw new PresignPreconditionMissingError(key, "if-match");
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
if (input.ifNoneMatch) {
|
|
866
|
-
const signed = lowerHeaders.get("if-none-match");
|
|
867
|
-
if (signed !== input.ifNoneMatch) {
|
|
868
|
-
throw new PresignPreconditionMissingError(key, "if-none-match");
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
async prime(op: PresignOp, keys: PresignKeyInput[]): Promise<void> {
|
|
874
|
-
if (keys.length === 0) return;
|
|
875
|
-
const chunks: PresignKeyInput[][] = [];
|
|
876
|
-
for (let i = 0; i < keys.length; i += PRIME_CHUNK) {
|
|
877
|
-
chunks.push(keys.slice(i, i + PRIME_CHUNK));
|
|
878
|
-
}
|
|
879
|
-
let next = 0;
|
|
880
|
-
const worker = async (): Promise<void> => {
|
|
881
|
-
while (next < chunks.length) {
|
|
882
|
-
// Budget already exhausted — stop priming; remaining keys defer.
|
|
883
|
-
if (this.breaker.isTripped()) return;
|
|
884
|
-
const chunk = chunks[next++];
|
|
885
|
-
let resp;
|
|
886
|
-
try {
|
|
887
|
-
resp = await this.vault.presign({
|
|
888
|
-
companyUid: this.companyUid,
|
|
889
|
-
op,
|
|
890
|
-
expiresIn: PRIME_EXPIRES_IN_SECONDS,
|
|
891
|
-
keys: chunk.map((k) => ({ ...k, op })),
|
|
892
|
-
});
|
|
893
|
-
} catch (err) {
|
|
894
|
-
if (isRateLimit(err)) {
|
|
895
|
-
// Budget exhausted mid-prime: trip the breaker and stop. Priming
|
|
896
|
-
// on means every remaining chunk + per-file fallback would 429 —
|
|
897
|
-
// the spiral. Tripping makes the transfer loop fail fast instead.
|
|
898
|
-
this.breaker.trip();
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
// A non-429 chunk failure just means those keys aren't cached — the
|
|
902
|
-
// per-key call will single-presign. Never let priming fail the sync.
|
|
903
|
-
continue;
|
|
904
|
-
}
|
|
905
|
-
const now = Date.now();
|
|
906
|
-
for (const row of resp.results) {
|
|
907
|
-
if (row.error || !row.url) continue;
|
|
908
|
-
this.urlCache.set(this.cacheKey(op, row.key), {
|
|
909
|
-
url: row.url,
|
|
910
|
-
headers: row.headers,
|
|
911
|
-
expiresAtMs: now + (row.expiresIn ?? PRIME_EXPIRES_IN_SECONDS) * 1000,
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
};
|
|
916
|
-
await Promise.all(
|
|
917
|
-
Array.from({ length: Math.min(PRIME_CONCURRENCY, chunks.length) }, worker),
|
|
918
|
-
);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
async putObject(input: PutObjectInput): Promise<{ etag: string }> {
|
|
922
|
-
// Conditional-write fields (ifMatch/ifNoneMatch) are forwarded on the
|
|
923
|
-
// presign request so the server can sign If-Match/If-None-Match into the
|
|
924
|
-
// URL and echo them via `headers` for replay. Do not inject a missing
|
|
925
|
-
// condition client-side: an unsigned conditional header breaks the SigV4
|
|
926
|
-
// signature. Strict validation of echoed headers remains feature-gated
|
|
927
|
-
// until the hq-pro files-presign server signs these conditions.
|
|
928
|
-
const row = await this.resolveUrl("put", input.key, {
|
|
929
|
-
contentType: input.contentType,
|
|
930
|
-
...(input.metadata && Object.keys(input.metadata).length > 0
|
|
931
|
-
? { metadata: input.metadata }
|
|
932
|
-
: {}),
|
|
933
|
-
...(input.ifMatch ? { ifMatch: stripQuotes(input.ifMatch) } : {}),
|
|
934
|
-
...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}),
|
|
935
|
-
});
|
|
936
|
-
if (isPresignFenceStrictEnabled()) {
|
|
937
|
-
this.requireSignedPutPreconditions(input.key, input, row.headers);
|
|
938
|
-
}
|
|
939
|
-
// The server signs Content-Type, SSE-KMS, and every x-amz-meta-* into the
|
|
940
|
-
// signature and returns them in `headers`; they MUST be replayed verbatim
|
|
941
|
-
// or SigV4 rejects the PUT.
|
|
942
|
-
const res = await fetchWithRetry(
|
|
943
|
-
row.url,
|
|
944
|
-
{ method: "PUT", body: input.body, headers: row.headers ?? {} },
|
|
945
|
-
`presigned PUT ${input.key}`,
|
|
946
|
-
);
|
|
947
|
-
if (res.status === 412) {
|
|
948
|
-
// The signed conditional header fenced this write off: the remote
|
|
949
|
-
// moved past our baseline (If-Match) or the key already exists
|
|
950
|
-
// (If-None-Match). Same shape as the SDK's PreconditionFailed so the
|
|
951
|
-
// push path routes both transports through one conflict handler.
|
|
952
|
-
const detail = await safeText(res);
|
|
953
|
-
throw preconditionFailedError(input.key, detail);
|
|
954
|
-
}
|
|
955
|
-
if (!res.ok) {
|
|
956
|
-
const detail = await safeText(res);
|
|
957
|
-
throw new Error(
|
|
958
|
-
`presigned PUT failed for ${input.key}: ${res.status} ${detail}`,
|
|
959
|
-
);
|
|
960
|
-
}
|
|
961
|
-
return { etag: stripQuotes(res.headers.get("etag") ?? undefined) };
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
async getObjectStream(key: string): Promise<GetObjectStreamResult> {
|
|
965
|
-
const row = await this.resolveGetUrlForBody(key);
|
|
966
|
-
const res = await presignedGetWithRetry(
|
|
967
|
-
row.url,
|
|
968
|
-
row.headers,
|
|
969
|
-
`presigned GET ${key}`,
|
|
970
|
-
);
|
|
971
|
-
if (res.status === 404) {
|
|
972
|
-
res.destroy();
|
|
973
|
-
throw notFoundError(key);
|
|
974
|
-
}
|
|
975
|
-
if (res.status < 200 || res.status >= 300) {
|
|
976
|
-
const detail = await safePresignedGetText(res);
|
|
977
|
-
throw new Error(`presigned GET failed for ${key}: ${res.status} ${detail}`);
|
|
978
|
-
}
|
|
979
|
-
return {
|
|
980
|
-
body: res.body ?? (async function* () {})(),
|
|
981
|
-
metadata: metaFromHeaders(res.headers),
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
async getObject(key: string): Promise<GetObjectResult> {
|
|
986
|
-
const res = await this.getObjectStream(key);
|
|
987
|
-
const body = await drainToBuffer(res.body);
|
|
988
|
-
return { body, metadata: res.metadata };
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
async listObjects(input: ListObjectsInput): Promise<ListObjectsResult> {
|
|
992
|
-
const { objects, cursor } = await this.vault.listFiles(
|
|
993
|
-
this.companyUid,
|
|
994
|
-
input.prefix,
|
|
995
|
-
input.continuationToken,
|
|
996
|
-
);
|
|
997
|
-
return {
|
|
998
|
-
objects: objects.map((o) => ({
|
|
999
|
-
key: o.key,
|
|
1000
|
-
size: o.size,
|
|
1001
|
-
lastModified: o.lastModified ? new Date(o.lastModified) : new Date(),
|
|
1002
|
-
etag: o.etag ?? "",
|
|
1003
|
-
})),
|
|
1004
|
-
nextContinuationToken: cursor ?? undefined,
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
async deleteObject(key: string): Promise<void> {
|
|
1009
|
-
const row = await this.resolveUrl("delete", key);
|
|
1010
|
-
const res = await fetchWithRetry(row.url, { method: "DELETE" }, `presigned DELETE ${key}`);
|
|
1011
|
-
// S3 DELETE is idempotent — a 204 (deleted) and a 404 (already gone) are
|
|
1012
|
-
// both success for the sync engine's purposes.
|
|
1013
|
-
if (!res.ok && res.status !== 404) {
|
|
1014
|
-
const detail = await safeText(res);
|
|
1015
|
-
throw new Error(
|
|
1016
|
-
`presigned DELETE failed for ${key}: ${res.status} ${detail}`,
|
|
1017
|
-
);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
async headObject(key: string): Promise<HeadObjectResult | null> {
|
|
1022
|
-
// The presign endpoint has no HEAD op (get/put/delete only). A presigned
|
|
1023
|
-
// GET signs the GET method, so we issue a real GET and read only the
|
|
1024
|
-
// response headers, cancelling the body stream before it downloads — the
|
|
1025
|
-
// headers (etag, content-length, last-modified, x-amz-meta-*) are all we
|
|
1026
|
-
// need and arrive before the body. Cheap for the created-at-preservation
|
|
1027
|
-
// and conflict-detection call sites that use headObject. Reuses the GET
|
|
1028
|
-
// cache: a prime("get", …) before a pull warms these HEADs for free.
|
|
1029
|
-
let url: string;
|
|
1030
|
-
const hit = this.consumeHeadReuse(key) ?? this.consumeCached("get", key);
|
|
1031
|
-
if (hit) {
|
|
1032
|
-
url = hit.url;
|
|
1033
|
-
} else {
|
|
1034
|
-
if (this.breaker.isTripped()) throw new RateLimitedError(key, "get");
|
|
1035
|
-
let results;
|
|
1036
|
-
try {
|
|
1037
|
-
({ results } = await this.vault.presign({
|
|
1038
|
-
companyUid: this.companyUid,
|
|
1039
|
-
op: "get",
|
|
1040
|
-
keys: [{ key, op: "get" }],
|
|
1041
|
-
}));
|
|
1042
|
-
} catch (err) {
|
|
1043
|
-
if (isRateLimit(err)) {
|
|
1044
|
-
this.breaker.trip();
|
|
1045
|
-
throw new RateLimitedError(key, "get", { cause: err });
|
|
1046
|
-
}
|
|
1047
|
-
throw err;
|
|
1048
|
-
}
|
|
1049
|
-
const row = results[0];
|
|
1050
|
-
if (row?.code === "FILES_PRESIGN_NOT_FOUND") {
|
|
1051
|
-
// The presign lambda HEAD'd S3 and confirmed the object does NOT
|
|
1052
|
-
// exist — an unambiguous absence, distinct from the read DENIAL
|
|
1053
|
-
// handled below. Report absent (null) so the push creates the new
|
|
1054
|
-
// object. Without this, a brand-new file's first upload is blocked
|
|
1055
|
-
// forever: its presigned GET 403s (S3 masks absence when the URL
|
|
1056
|
-
// lacks `s3:ListBucket`) and routes through the access-denied skip
|
|
1057
|
-
// path. The server's explicit NOT_FOUND is the ONLY safe absence
|
|
1058
|
-
// signal — a 403 stays "unknown", never "missing" (see below).
|
|
1059
|
-
return null;
|
|
1060
|
-
}
|
|
1061
|
-
if (!row || row.error || !row.url) {
|
|
1062
|
-
// A per-key denial means the caller can't READ the key — it says
|
|
1063
|
-
// nothing about whether the object EXISTS. Pre-fix this returned
|
|
1064
|
-
// null ("absent"), which made push call sites skip every conflict
|
|
1065
|
-
// guard (`if (remoteMeta)`) and issue an UNCONDITIONAL PUT — a
|
|
1066
|
-
// transient denial episode mid-pass silently clobbered newer remote
|
|
1067
|
-
// bytes with this machine's stale copy (the 2026-06-10..12 vault
|
|
1068
|
-
// regression storm). Throw the same access-denied shape the SDK
|
|
1069
|
-
// transport raises so callers route through their existing
|
|
1070
|
-
// isAccessDenied skip/defer paths instead of "object missing".
|
|
1071
|
-
throw accessDeniedError(key, row?.error ?? "presign denied");
|
|
1072
|
-
}
|
|
1073
|
-
url = row.url;
|
|
1074
|
-
}
|
|
1075
|
-
const res = await presignedGetWithRetry(
|
|
1076
|
-
url,
|
|
1077
|
-
undefined,
|
|
1078
|
-
`presigned HEAD ${key}`,
|
|
1079
|
-
);
|
|
1080
|
-
if (res.status === 404) {
|
|
1081
|
-
res.destroy();
|
|
1082
|
-
return null;
|
|
1083
|
-
}
|
|
1084
|
-
if (res.status === 403) {
|
|
1085
|
-
// 403 on the signed GET (expired URL, expired signing creds, KMS or
|
|
1086
|
-
// bucket-policy denial) is UNKNOWN state, not absence — see the presign
|
|
1087
|
-
// denial branch above. The SDK transport throws name:"Forbidden" here;
|
|
1088
|
-
// mirror it so both transports agree and no caller mistakes a denial
|
|
1089
|
-
// for a missing object.
|
|
1090
|
-
res.destroy();
|
|
1091
|
-
throw accessDeniedError(key, "presigned HEAD returned 403");
|
|
1092
|
-
}
|
|
1093
|
-
if (res.status < 200 || res.status >= 300) {
|
|
1094
|
-
const detail = await safePresignedGetText(res);
|
|
1095
|
-
throw new Error(`presigned HEAD failed for ${key}: ${res.status} ${detail}`);
|
|
1096
|
-
}
|
|
1097
|
-
const result: HeadObjectResult = {
|
|
1098
|
-
lastModified: parseLastModified(res.headers.get("last-modified")),
|
|
1099
|
-
etag: stripQuotes(res.headers.get("etag") ?? undefined),
|
|
1100
|
-
size: Number(res.headers.get("content-length") ?? "0"),
|
|
1101
|
-
metadata: metaFromHeaders(res.headers),
|
|
1102
|
-
};
|
|
1103
|
-
res.destroy();
|
|
1104
|
-
return result;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
async function safeText(res: Response): Promise<string> {
|
|
1109
|
-
try {
|
|
1110
|
-
return (await res.text()).slice(0, 200);
|
|
1111
|
-
} catch {
|
|
1112
|
-
return "";
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
async function cancelBody(res: Response): Promise<void> {
|
|
1117
|
-
try {
|
|
1118
|
-
await res.body?.cancel();
|
|
1119
|
-
} catch {
|
|
1120
|
-
// Best-effort — the socket is released either way once GC'd.
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
function parseLastModified(value: string | null): Date {
|
|
1125
|
-
if (!value) return new Date();
|
|
1126
|
-
const d = new Date(value);
|
|
1127
|
-
return Number.isNaN(d.getTime()) ? new Date() : d;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// ---------------------------------------------------------------------------
|
|
1131
|
-
// Transient-failure retry for the presigned-URL fetches
|
|
1132
|
-
// ---------------------------------------------------------------------------
|
|
1133
|
-
//
|
|
1134
|
-
// The AWS S3 SDK retries transient errors (5xx, throttling, dropped sockets)
|
|
1135
|
-
// automatically with backoff. Moving the byte transfer to `fetch` over a
|
|
1136
|
-
// presigned URL dropped that resilience — and at sync scale (thousands of
|
|
1137
|
-
// objects per pull) transient S3 5xx (notably 503 SlowDown) are routine, so
|
|
1138
|
-
// without retry a large sync sporadically loses files. This restores
|
|
1139
|
-
// SDK-parity: retry network errors and transient 5xx with exponential backoff
|
|
1140
|
-
// + jitter; 4xx (404/403) are definitive and pass straight through.
|
|
1141
|
-
|
|
1142
|
-
const FETCH_MAX_RETRIES = 3;
|
|
1143
|
-
const FETCH_BASE_DELAY_MS = 400;
|
|
1144
|
-
|
|
1145
|
-
function isTransientStatus(status: number): boolean {
|
|
1146
|
-
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
function sleep(ms: number): Promise<void> {
|
|
1150
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
/**
|
|
1154
|
-
* Shared presigned-GET path for streaming reads and header-only probes.
|
|
1155
|
-
*
|
|
1156
|
-
* GET deliberately uses Node core HTTP(S), not global fetch: on affected
|
|
1157
|
-
* Node/Windows builds the bundled undici parser can assert after a FIN arrives
|
|
1158
|
-
* while the consumer is backpressured. Keep the retry budget identical to the
|
|
1159
|
-
* PUT/DELETE fetch path below.
|
|
1160
|
-
*/
|
|
1161
|
-
async function presignedGetWithRetry(
|
|
1162
|
-
url: string,
|
|
1163
|
-
headers: Record<string, string> | undefined,
|
|
1164
|
-
what: string,
|
|
1165
|
-
): Promise<PresignedGetResponse> {
|
|
1166
|
-
let lastError: unknown;
|
|
1167
|
-
for (let attempt = 0; attempt <= FETCH_MAX_RETRIES; attempt++) {
|
|
1168
|
-
if (attempt > 0) {
|
|
1169
|
-
const backoff = FETCH_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
1170
|
-
const jitter = Math.floor(Math.random() * FETCH_BASE_DELAY_MS);
|
|
1171
|
-
await sleep(backoff + jitter);
|
|
1172
|
-
}
|
|
1173
|
-
let res: PresignedGetResponse;
|
|
1174
|
-
try {
|
|
1175
|
-
res = await presignedGetTransport.get(url, headers);
|
|
1176
|
-
} catch (err) {
|
|
1177
|
-
lastError = err;
|
|
1178
|
-
continue;
|
|
1179
|
-
}
|
|
1180
|
-
if (isTransientStatus(res.status) && attempt < FETCH_MAX_RETRIES) {
|
|
1181
|
-
res.destroy();
|
|
1182
|
-
lastError = new Error(`${what}: transient ${res.status}`);
|
|
1183
|
-
continue;
|
|
1184
|
-
}
|
|
1185
|
-
return res;
|
|
1186
|
-
}
|
|
1187
|
-
throw lastError instanceof Error
|
|
1188
|
-
? lastError
|
|
1189
|
-
: new Error(`${what}: failed after ${FETCH_MAX_RETRIES} retries`);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
async function safePresignedGetText(res: PresignedGetResponse): Promise<string> {
|
|
1193
|
-
try {
|
|
1194
|
-
let text = "";
|
|
1195
|
-
for await (const chunk of res.body ?? []) {
|
|
1196
|
-
text += Buffer.from(chunk).toString("utf-8");
|
|
1197
|
-
if (text.length >= 200) break;
|
|
1198
|
-
}
|
|
1199
|
-
return text.slice(0, 200);
|
|
1200
|
-
} catch {
|
|
1201
|
-
return "";
|
|
1202
|
-
} finally {
|
|
1203
|
-
res.destroy();
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
/**
|
|
1208
|
-
* `fetch` with bounded retry on network errors + transient 5xx. The presigned
|
|
1209
|
-
* URL is reusable until expiry and the bodies are in-memory Buffers, so a
|
|
1210
|
-
* retry simply re-issues the same request. Jitter avoids a thundering-herd
|
|
1211
|
-
* re-retry when the whole transfer pool hits a 503 SlowDown at once. After
|
|
1212
|
-
* exhausting retries on a 5xx it returns the final (failing) Response so the
|
|
1213
|
-
* caller's normal status handling reports it; network errors throw.
|
|
1214
|
-
*/
|
|
1215
|
-
async function fetchWithRetry(
|
|
1216
|
-
url: string,
|
|
1217
|
-
init: RequestInit,
|
|
1218
|
-
what: string,
|
|
1219
|
-
): Promise<Response> {
|
|
1220
|
-
let lastError: unknown;
|
|
1221
|
-
for (let attempt = 0; attempt <= FETCH_MAX_RETRIES; attempt++) {
|
|
1222
|
-
if (attempt > 0) {
|
|
1223
|
-
const backoff = FETCH_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
1224
|
-
const jitter = Math.floor(Math.random() * FETCH_BASE_DELAY_MS);
|
|
1225
|
-
await sleep(backoff + jitter);
|
|
1226
|
-
}
|
|
1227
|
-
let res: Response;
|
|
1228
|
-
try {
|
|
1229
|
-
res = await fetch(url, init);
|
|
1230
|
-
} catch (err) {
|
|
1231
|
-
lastError = err; // socket reset / DNS / TLS — retry
|
|
1232
|
-
continue;
|
|
1233
|
-
}
|
|
1234
|
-
if (isTransientStatus(res.status) && attempt < FETCH_MAX_RETRIES) {
|
|
1235
|
-
await cancelBody(res); // free the socket before backoff
|
|
1236
|
-
lastError = new Error(`${what}: transient ${res.status}`);
|
|
1237
|
-
continue;
|
|
1238
|
-
}
|
|
1239
|
-
return res; // success, a non-transient status, or the last 5xx attempt
|
|
1240
|
-
}
|
|
1241
|
-
throw lastError instanceof Error
|
|
1242
|
-
? lastError
|
|
1243
|
-
: new Error(`${what}: failed after ${FETCH_MAX_RETRIES} retries`);
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// ---------------------------------------------------------------------------
|
|
1247
|
-
// Factory registry — selected once per session by runRunner
|
|
1248
|
-
// ---------------------------------------------------------------------------
|
|
1249
|
-
|
|
1250
|
-
export type ObjectIOFactory = (ctx: EntityContext) => ObjectIO;
|
|
1251
|
-
|
|
1252
|
-
const DEFAULT_FACTORY: ObjectIOFactory = (ctx) => new S3SdkObjectIO(ctx);
|
|
1253
|
-
|
|
1254
|
-
let activeFactory: ObjectIOFactory = DEFAULT_FACTORY;
|
|
1255
|
-
|
|
1256
|
-
/**
|
|
1257
|
-
* Install the transport factory for the current process. Passing `null`
|
|
1258
|
-
* resets to the default S3 SDK transport. Called once by `runRunner` after it
|
|
1259
|
-
* resolves the caller's identity + feature-flag gate; every subsequent s3.ts
|
|
1260
|
-
* call resolves its transport through this.
|
|
1261
|
-
*/
|
|
1262
|
-
export function setObjectIOFactory(factory: ObjectIOFactory | null): void {
|
|
1263
|
-
activeFactory = factory ?? DEFAULT_FACTORY;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
/** Resolve the transport for an EntityContext using the active factory. */
|
|
1267
|
-
export function resolveObjectIO(ctx: EntityContext): ObjectIO {
|
|
1268
|
-
return activeFactory(ctx);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Build a factory that routes every EntityContext through the presigned-URL
|
|
1273
|
-
* transport, reusing the one already-authenticated VaultClient and deriving
|
|
1274
|
-
* the per-company authority from `ctx.uid`.
|
|
1275
|
-
*/
|
|
1276
|
-
export function presignObjectIOFactory(
|
|
1277
|
-
vault: PresignTransportClient,
|
|
1278
|
-
): ObjectIOFactory {
|
|
1279
|
-
// Memoize one PresignObjectIO per company for the run, so a prime() and the
|
|
1280
|
-
// transfer loop that drains its URL cache share the SAME instance. (Safe to
|
|
1281
|
-
// memoize: PresignObjectIO holds only the vault client — whose token is a
|
|
1282
|
-
// live getter — and the stable companyUid; it captures no rotating
|
|
1283
|
-
// credentials, unlike S3SdkObjectIO, which is why the default factory is
|
|
1284
|
-
// intentionally NOT memoized.)
|
|
1285
|
-
// One breaker per run, shared across companies: the 100/hr budget is
|
|
1286
|
-
// per-user, so a 429 in one company means the whole run should stop minting.
|
|
1287
|
-
const breaker = new RateLimitBreaker();
|
|
1288
|
-
const byCompany = new Map<string, PresignObjectIO>();
|
|
1289
|
-
return (ctx) => {
|
|
1290
|
-
// Personal vaults are a PERSON entity (prs_*) accessed via the membership-
|
|
1291
|
-
// less vend-self model; the list/presign endpoints are membership-gated and
|
|
1292
|
-
// 403 ("no active membership in company prs_…") for them. Personal vaults
|
|
1293
|
-
// also have no ACL-scale problem (single owner), so they don't need
|
|
1294
|
-
// presign — keep them on the S3 SDK (STS) transport. Presign is for company
|
|
1295
|
-
// vaults (cmp_*), which is where the unbounded-grants problem lives.
|
|
1296
|
-
if (!ctx.uid.startsWith("cmp_")) {
|
|
1297
|
-
return new S3SdkObjectIO(ctx);
|
|
1298
|
-
}
|
|
1299
|
-
let io = byCompany.get(ctx.uid);
|
|
1300
|
-
if (!io) {
|
|
1301
|
-
io = new PresignObjectIO(vault, ctx.uid, breaker);
|
|
1302
|
-
byCompany.set(ctx.uid, io);
|
|
1303
|
-
}
|
|
1304
|
-
return io;
|
|
1305
|
-
};
|
|
1306
|
-
}
|