@hasna/instructions 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -8
- package/dist/chunks/apply-7svah2qm.js +25 -0
- package/dist/chunks/apply-jp13sa1e.js +25 -0
- package/dist/chunks/database-wstmr56z.js +23 -0
- package/dist/chunks/index-1cnyjnd7.js +623 -0
- package/dist/chunks/index-35z65jsa.js +198 -0
- package/dist/chunks/index-3djthcm6.js +117 -0
- package/dist/chunks/index-4pfq42t5.js +7300 -0
- package/dist/chunks/index-5d7zdftc.js +186 -0
- package/dist/chunks/index-7m4j8a33.js +12578 -0
- package/dist/chunks/index-8rb7ng2y.js +148 -0
- package/dist/chunks/index-arrrx0ed.js +344 -0
- package/dist/chunks/index-bsan4c16.js +48 -0
- package/dist/chunks/index-bzyxvwcd.js +625 -0
- package/dist/chunks/index-fpeyzn3n.js +186 -0
- package/dist/chunks/index-fwbs6tma.js +534 -0
- package/dist/chunks/index-ma2xmp6c.js +14 -0
- package/dist/chunks/index-n3fjspr0.js +119 -0
- package/dist/chunks/index-nnwp92tk.js +19 -0
- package/dist/chunks/index-z3ma4aqk.js +532 -0
- package/dist/chunks/legacy-store-migration-z2m5yzyk.js +322 -0
- package/dist/chunks/local-5wvv5xv3.js +694 -0
- package/dist/chunks/local-zxa5dpf4.js +518 -0
- package/dist/chunks/package-manager-guard-x70xrwk2.js +391 -0
- package/dist/chunks/redact-hnq4sc4r.js +16 -0
- package/dist/chunks/s3-backup-n2z4fwyh.js +240 -0
- package/dist/chunks/s3-config-hhght91k.js +14 -0
- package/dist/chunks/s3-object-store-90tgbh6k.js +14 -0
- package/dist/chunks/sync-2075deve.js +34 -0
- package/dist/chunks/sync-r8t7zsp3.js +34 -0
- package/dist/chunks/template-fsfxpe4p.js +16 -0
- package/dist/chunks/template-wgnzdn7h.js +16 -0
- package/dist/cli/index.js +1681 -15643
- package/dist/data/config-store.d.ts +2 -1
- package/dist/data/config-store.d.ts.map +1 -1
- package/dist/db/database.d.ts +3 -5
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/local.d.ts +38 -0
- package/dist/db/local.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13809 -12998
- package/dist/lib/apply.d.ts +7 -0
- package/dist/lib/apply.d.ts.map +1 -1
- package/dist/lib/compact-output.d.ts +1 -0
- package/dist/lib/compact-output.d.ts.map +1 -1
- package/dist/lib/export.d.ts +22 -3
- package/dist/lib/export.d.ts.map +1 -1
- package/dist/lib/ids.d.ts +4 -0
- package/dist/lib/ids.d.ts.map +1 -0
- package/dist/lib/import.d.ts +18 -0
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/instruction-graph.d.ts.map +1 -1
- package/dist/lib/legacy-store-migration.d.ts +39 -0
- package/dist/lib/legacy-store-migration.d.ts.map +1 -0
- package/dist/lib/managed-skill-runtimes.d.ts +1 -0
- package/dist/lib/managed-skill-runtimes.d.ts.map +1 -1
- package/dist/lib/session-render.d.ts +3 -0
- package/dist/lib/session-render.d.ts.map +1 -1
- package/dist/lib/sync-dir.d.ts.map +1 -1
- package/dist/mcp/index.js +73 -9462
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +205 -10
- package/dist/sdk/resolve.d.ts +1 -1
- package/dist/sdk/resolve.d.ts.map +1 -1
- package/dist/sdk/v1-client.d.ts +57 -0
- package/dist/sdk/v1-client.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +212 -21
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.d.ts +13 -5
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +8337 -7495
- package/dist/server/openapi.d.ts +1264 -92
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts +25 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/status.d.ts +12 -2
- package/dist/status.d.ts.map +1 -1
- package/dist/storage/cloud-store.d.ts +57 -1
- package/dist/storage/cloud-store.d.ts.map +1 -1
- package/dist/storage/index.d.ts +7 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +725 -0
- package/dist/storage/s3-backup.d.ts +76 -0
- package/dist/storage/s3-backup.d.ts.map +1 -0
- package/dist/storage/s3-config.d.ts +46 -0
- package/dist/storage/s3-config.d.ts.map +1 -0
- package/dist/storage/s3-object-store.d.ts +70 -0
- package/dist/storage/s3-object-store.d.ts.map +1 -0
- package/dist/types/index.d.ts +65 -3
- package/dist/types/index.d.ts.map +1 -1
- package/hasna.contract.json +110 -0
- package/migrations/0001_instructions.sql +63 -0
- package/migrations/0002_api_keys.sql +20 -0
- package/migrations/0003_idempotency_receipts.sql +24 -0
- package/package.json +29 -14
- package/assets/skills/inbox/SKILL.md +0 -86
- package/dist/cli/add-duplicate-target.test.d.ts +0 -2
- package/dist/cli/add-duplicate-target.test.d.ts.map +0 -1
- package/dist/cli/add-reference-update.test.d.ts +0 -2
- package/dist/cli/add-reference-update.test.d.ts.map +0 -1
- package/dist/cli/apply-unchanged-snapshot-bloat.test.d.ts +0 -2
- package/dist/cli/apply-unchanged-snapshot-bloat.test.d.ts.map +0 -1
- package/dist/cli/doctor-reference-duplicates.test.d.ts +0 -2
- package/dist/cli/doctor-reference-duplicates.test.d.ts.map +0 -1
- package/dist/cli/fail-closed-no-env.test.d.ts +0 -2
- package/dist/cli/fail-closed-no-env.test.d.ts.map +0 -1
- package/dist/cli/output.test.d.ts +0 -2
- package/dist/cli/output.test.d.ts.map +0 -1
- package/dist/cli/profile-binding.test.d.ts +0 -2
- package/dist/cli/profile-binding.test.d.ts.map +0 -1
- package/dist/cli/profile-reads.test.d.ts +0 -2
- package/dist/cli/profile-reads.test.d.ts.map +0 -1
- package/dist/cli/profile-update.test.d.ts +0 -2
- package/dist/cli/profile-update.test.d.ts.map +0 -1
- package/dist/cli/project-context.test.d.ts +0 -2
- package/dist/cli/project-context.test.d.ts.map +0 -1
- package/dist/cli/raw-store-root.test.d.ts +0 -2
- package/dist/cli/raw-store-root.test.d.ts.map +0 -1
- package/dist/cli/session-apply-managed-input-bound.test.d.ts +0 -2
- package/dist/cli/session-apply-managed-input-bound.test.d.ts.map +0 -1
- package/dist/cli/session.test.d.ts +0 -2
- package/dist/cli/session.test.d.ts.map +0 -1
- package/dist/cli/station-profile.test.d.ts +0 -2
- package/dist/cli/station-profile.test.d.ts.map +0 -1
- package/dist/data/config-store.test.d.ts +0 -2
- package/dist/data/config-store.test.d.ts.map +0 -1
- package/dist/db/configs.test.d.ts +0 -2
- package/dist/db/configs.test.d.ts.map +0 -1
- package/dist/db/database.test.d.ts +0 -2
- package/dist/db/database.test.d.ts.map +0 -1
- package/dist/db/machines.test.d.ts +0 -2
- package/dist/db/machines.test.d.ts.map +0 -1
- package/dist/db/profile-assets.test.d.ts +0 -2
- package/dist/db/profile-assets.test.d.ts.map +0 -1
- package/dist/db/profile-config-bindings.test.d.ts +0 -2
- package/dist/db/profile-config-bindings.test.d.ts.map +0 -1
- package/dist/db/profiles.test.d.ts +0 -2
- package/dist/db/profiles.test.d.ts.map +0 -1
- package/dist/db/snapshots.test.d.ts +0 -2
- package/dist/db/snapshots.test.d.ts.map +0 -1
- package/dist/lib/app-home.test.d.ts +0 -2
- package/dist/lib/app-home.test.d.ts.map +0 -1
- package/dist/lib/apply-batch.test.d.ts +0 -2
- package/dist/lib/apply-batch.test.d.ts.map +0 -1
- package/dist/lib/apply-dry-run-verdict.test.d.ts +0 -2
- package/dist/lib/apply-dry-run-verdict.test.d.ts.map +0 -1
- package/dist/lib/apply-secret-placeholder-guard.test.d.ts +0 -2
- package/dist/lib/apply-secret-placeholder-guard.test.d.ts.map +0 -1
- package/dist/lib/apply-text-format-credential-guard.test.d.ts +0 -2
- package/dist/lib/apply-text-format-credential-guard.test.d.ts.map +0 -1
- package/dist/lib/apply.test.d.ts +0 -2
- package/dist/lib/apply.test.d.ts.map +0 -1
- package/dist/lib/asset-plan.test.d.ts +0 -2
- package/dist/lib/asset-plan.test.d.ts.map +0 -1
- package/dist/lib/client-types.test.d.ts +0 -2
- package/dist/lib/client-types.test.d.ts.map +0 -1
- package/dist/lib/codewith-shared-todos-storage-standard.test.d.ts +0 -2
- package/dist/lib/codewith-shared-todos-storage-standard.test.d.ts.map +0 -1
- package/dist/lib/compact-output.test.d.ts +0 -2
- package/dist/lib/compact-output.test.d.ts.map +0 -1
- package/dist/lib/config-target-identity.test.d.ts +0 -2
- package/dist/lib/config-target-identity.test.d.ts.map +0 -1
- package/dist/lib/cursor-authority.test.d.ts +0 -2
- package/dist/lib/cursor-authority.test.d.ts.map +0 -1
- package/dist/lib/dangerous-operation-guard-standard.test.d.ts +0 -2
- package/dist/lib/dangerous-operation-guard-standard.test.d.ts.map +0 -1
- package/dist/lib/diff-redaction.test.d.ts +0 -2
- package/dist/lib/diff-redaction.test.d.ts.map +0 -1
- package/dist/lib/export-import.test.d.ts +0 -2
- package/dist/lib/export-import.test.d.ts.map +0 -1
- package/dist/lib/global-agent-rules-render-integration.test.d.ts +0 -2
- package/dist/lib/global-agent-rules-render-integration.test.d.ts.map +0 -1
- package/dist/lib/global-agent-rules-standard.test.d.ts +0 -2
- package/dist/lib/global-agent-rules-standard.test.d.ts.map +0 -1
- package/dist/lib/global-agent-rules-stored-content.test.d.ts +0 -2
- package/dist/lib/global-agent-rules-stored-content.test.d.ts.map +0 -1
- package/dist/lib/global-source-coverage.test.d.ts +0 -2
- package/dist/lib/global-source-coverage.test.d.ts.map +0 -1
- package/dist/lib/instruction-graph-apply.test.d.ts +0 -2
- package/dist/lib/instruction-graph-apply.test.d.ts.map +0 -1
- package/dist/lib/instruction-graph.test.d.ts +0 -2
- package/dist/lib/instruction-graph.test.d.ts.map +0 -1
- package/dist/lib/machine.test.d.ts +0 -2
- package/dist/lib/machine.test.d.ts.map +0 -1
- package/dist/lib/managed-skill-runtimes.test.d.ts +0 -2
- package/dist/lib/managed-skill-runtimes.test.d.ts.map +0 -1
- package/dist/lib/package-manager-guard.test.d.ts +0 -2
- package/dist/lib/package-manager-guard.test.d.ts.map +0 -1
- package/dist/lib/project-context-prepared-file.test.d.ts +0 -2
- package/dist/lib/project-context-prepared-file.test.d.ts.map +0 -1
- package/dist/lib/project-context.test.d.ts +0 -2
- package/dist/lib/project-context.test.d.ts.map +0 -1
- package/dist/lib/project-dashboard-standard.test.d.ts +0 -2
- package/dist/lib/project-dashboard-standard.test.d.ts.map +0 -1
- package/dist/lib/provider-assets-adapters.test.d.ts +0 -2
- package/dist/lib/provider-assets-adapters.test.d.ts.map +0 -1
- package/dist/lib/provider-context.test.d.ts +0 -2
- package/dist/lib/provider-context.test.d.ts.map +0 -1
- package/dist/lib/provider-version-adapters.test.d.ts +0 -2
- package/dist/lib/provider-version-adapters.test.d.ts.map +0 -1
- package/dist/lib/redact.test.d.ts +0 -2
- package/dist/lib/redact.test.d.ts.map +0 -1
- package/dist/lib/session-apply.test.d.ts +0 -2
- package/dist/lib/session-apply.test.d.ts.map +0 -1
- package/dist/lib/session-authority.test.d.ts +0 -2
- package/dist/lib/session-authority.test.d.ts.map +0 -1
- package/dist/lib/session-render-managed-output-bound.test.d.ts +0 -2
- package/dist/lib/session-render-managed-output-bound.test.d.ts.map +0 -1
- package/dist/lib/session-render-ownership.test.d.ts +0 -2
- package/dist/lib/session-render-ownership.test.d.ts.map +0 -1
- package/dist/lib/session-render-rule-currency-floor.test.d.ts +0 -2
- package/dist/lib/session-render-rule-currency-floor.test.d.ts.map +0 -1
- package/dist/lib/session-render-silent-source-drop.test.d.ts +0 -2
- package/dist/lib/session-render-silent-source-drop.test.d.ts.map +0 -1
- package/dist/lib/session-render-state-hermeticity.test.d.ts +0 -2
- package/dist/lib/session-render-state-hermeticity.test.d.ts.map +0 -1
- package/dist/lib/session-render-state.test.d.ts +0 -2
- package/dist/lib/session-render-state.test.d.ts.map +0 -1
- package/dist/lib/session-render.test.d.ts +0 -2
- package/dist/lib/session-render.test.d.ts.map +0 -1
- package/dist/lib/station-profile.test.d.ts +0 -2
- package/dist/lib/station-profile.test.d.ts.map +0 -1
- package/dist/lib/sync-dir.test.d.ts +0 -2
- package/dist/lib/sync-dir.test.d.ts.map +0 -1
- package/dist/lib/sync-dry-run-unchanged.test.d.ts +0 -2
- package/dist/lib/sync-dry-run-unchanged.test.d.ts.map +0 -1
- package/dist/lib/sync-known.test.d.ts +0 -2
- package/dist/lib/sync-known.test.d.ts.map +0 -1
- package/dist/lib/sync.test.d.ts +0 -2
- package/dist/lib/sync.test.d.ts.map +0 -1
- package/dist/lib/template.test.d.ts +0 -2
- package/dist/lib/template.test.d.ts.map +0 -1
- package/dist/lib/transforms.test.d.ts +0 -2
- package/dist/lib/transforms.test.d.ts.map +0 -1
- package/dist/lib/transport-resolver.test.d.ts +0 -2
- package/dist/lib/transport-resolver.test.d.ts.map +0 -1
- package/dist/mcp/create-config-target-guard.test.d.ts +0 -2
- package/dist/mcp/create-config-target-guard.test.d.ts.map +0 -1
- package/dist/mcp/early-args.test.d.ts +0 -2
- package/dist/mcp/early-args.test.d.ts.map +0 -1
- package/dist/mcp/http.test.d.ts +0 -2
- package/dist/mcp/http.test.d.ts.map +0 -1
- package/dist/mcp/mcp.test.d.ts +0 -2
- package/dist/mcp/mcp.test.d.ts.map +0 -1
- package/dist/sdk/resolve.test.d.ts +0 -2
- package/dist/sdk/resolve.test.d.ts.map +0 -1
- package/dist/sdk/sdk-bundle-self-contained.test.d.ts +0 -2
- package/dist/sdk/sdk-bundle-self-contained.test.d.ts.map +0 -1
- package/dist/server/cloud-auth.test.d.ts +0 -2
- package/dist/server/cloud-auth.test.d.ts.map +0 -1
- package/dist/server/cloud.test.d.ts +0 -2
- package/dist/server/cloud.test.d.ts.map +0 -1
- package/dist/server/early-args.test.d.ts +0 -2
- package/dist/server/early-args.test.d.ts.map +0 -1
- package/dist/server/profile-contract.test.d.ts +0 -2
- package/dist/server/profile-contract.test.d.ts.map +0 -1
- package/dist/status.test.d.ts +0 -2
- package/dist/status.test.d.ts.map +0 -1
- package/dist/storage/cloud-store.test.d.ts +0 -2
- package/dist/storage/cloud-store.test.d.ts.map +0 -1
- package/dist/test-support/preload-state-home.d.ts +0 -2
- package/dist/test-support/preload-state-home.d.ts.map +0 -1
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/storage/s3-config.ts
|
|
3
|
+
var INSTRUCTIONS_S3_ENV = {
|
|
4
|
+
bucket: "HASNA_INSTRUCTIONS_S3_BUCKET",
|
|
5
|
+
prefix: "HASNA_INSTRUCTIONS_S3_PREFIX",
|
|
6
|
+
region: "HASNA_INSTRUCTIONS_AWS_REGION",
|
|
7
|
+
endpoint: "HASNA_INSTRUCTIONS_S3_ENDPOINT",
|
|
8
|
+
forcePathStyle: "HASNA_INSTRUCTIONS_S3_FORCE_PATH_STYLE",
|
|
9
|
+
accessKeyId: "HASNA_INSTRUCTIONS_S3_ACCESS_KEY_ID",
|
|
10
|
+
secretAccessKey: "HASNA_INSTRUCTIONS_S3_SECRET_ACCESS_KEY",
|
|
11
|
+
sessionToken: "HASNA_INSTRUCTIONS_S3_SESSION_TOKEN"
|
|
12
|
+
};
|
|
13
|
+
var INSTRUCTIONS_S3_ALIAS_ENV = {
|
|
14
|
+
bucket: "INSTRUCTIONS_S3_BUCKET",
|
|
15
|
+
prefix: "INSTRUCTIONS_S3_PREFIX",
|
|
16
|
+
region: "INSTRUCTIONS_AWS_REGION",
|
|
17
|
+
endpoint: "INSTRUCTIONS_S3_ENDPOINT",
|
|
18
|
+
forcePathStyle: "INSTRUCTIONS_S3_FORCE_PATH_STYLE",
|
|
19
|
+
accessKeyId: "INSTRUCTIONS_S3_ACCESS_KEY_ID",
|
|
20
|
+
secretAccessKey: "INSTRUCTIONS_S3_SECRET_ACCESS_KEY",
|
|
21
|
+
sessionToken: "INSTRUCTIONS_S3_SESSION_TOKEN"
|
|
22
|
+
};
|
|
23
|
+
var BUCKET_PATTERN = /^(?!xn--)[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$/;
|
|
24
|
+
var REGION_PATTERN = /^[a-z]{2}(?:-gov)?-[a-z0-9-]+-\d$/;
|
|
25
|
+
var CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
26
|
+
function loadInstructionsS3Config(env = process.env) {
|
|
27
|
+
const bucket = readEnv(env, "bucket");
|
|
28
|
+
const configuredWithoutBucket = Object.keys(INSTRUCTIONS_S3_ENV).filter((key) => key !== "bucket").some((key) => readEnv(env, key) !== undefined);
|
|
29
|
+
if (!bucket) {
|
|
30
|
+
if (configuredWithoutBucket) {
|
|
31
|
+
throw new Error(`${INSTRUCTIONS_S3_ENV.bucket} is required when any Instructions S3 setting is configured`);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
validateBucket(bucket);
|
|
36
|
+
const prefix = normalizeInstructionsS3Prefix(readEnv(env, "prefix") ?? "instructions/");
|
|
37
|
+
const region = readEnv(env, "region") ?? "us-east-1";
|
|
38
|
+
validateRegion(region);
|
|
39
|
+
const endpoint = normalizeEndpoint(readEnv(env, "endpoint"));
|
|
40
|
+
const forcePathStyle = parseBoolean(readEnv(env, "forcePathStyle"), false);
|
|
41
|
+
const accessKeyId = readEnv(env, "accessKeyId");
|
|
42
|
+
const secretAccessKey = readEnv(env, "secretAccessKey");
|
|
43
|
+
const sessionToken = readEnv(env, "sessionToken");
|
|
44
|
+
if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) {
|
|
45
|
+
throw new Error(`${INSTRUCTIONS_S3_ENV.accessKeyId} and ${INSTRUCTIONS_S3_ENV.secretAccessKey} must be configured together`);
|
|
46
|
+
}
|
|
47
|
+
if (sessionToken && (!accessKeyId || !secretAccessKey)) {
|
|
48
|
+
throw new Error(`${INSTRUCTIONS_S3_ENV.sessionToken} requires a complete static credential pair`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
provider: "s3",
|
|
52
|
+
bucket,
|
|
53
|
+
prefix,
|
|
54
|
+
region,
|
|
55
|
+
...endpoint ? { endpoint } : {},
|
|
56
|
+
forcePathStyle,
|
|
57
|
+
...accessKeyId && secretAccessKey ? {
|
|
58
|
+
credentials: {
|
|
59
|
+
accessKeyId,
|
|
60
|
+
secretAccessKey,
|
|
61
|
+
...sessionToken ? { sessionToken } : {}
|
|
62
|
+
}
|
|
63
|
+
} : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function normalizeInstructionsS3Prefix(value) {
|
|
67
|
+
const trimmed = value.trim();
|
|
68
|
+
if (!trimmed || CONTROL_CHARACTERS.test(trimmed) || trimmed.includes("\\")) {
|
|
69
|
+
throw new Error("Instructions S3 prefix is invalid");
|
|
70
|
+
}
|
|
71
|
+
const withoutEdges = trimmed.replace(/^\/+|\/+$/g, "");
|
|
72
|
+
if (!withoutEdges || withoutEdges.includes("//")) {
|
|
73
|
+
throw new Error("Instructions S3 prefix is invalid");
|
|
74
|
+
}
|
|
75
|
+
const segments = withoutEdges.split("/");
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
let decoded;
|
|
78
|
+
try {
|
|
79
|
+
decoded = decodeURIComponent(segment);
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error("Instructions S3 prefix contains invalid percent encoding");
|
|
82
|
+
}
|
|
83
|
+
if (!decoded || decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || CONTROL_CHARACTERS.test(decoded)) {
|
|
84
|
+
throw new Error("Instructions S3 prefix contains an unsafe path segment");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return `${segments.join("/")}/`;
|
|
88
|
+
}
|
|
89
|
+
function readEnv(env, key) {
|
|
90
|
+
const canonical = INSTRUCTIONS_S3_ENV[key];
|
|
91
|
+
if (Object.prototype.hasOwnProperty.call(env, canonical))
|
|
92
|
+
return clean(env[canonical]);
|
|
93
|
+
return clean(env[INSTRUCTIONS_S3_ALIAS_ENV[key]]);
|
|
94
|
+
}
|
|
95
|
+
function clean(value) {
|
|
96
|
+
const trimmed = value?.trim();
|
|
97
|
+
return trimmed ? trimmed : undefined;
|
|
98
|
+
}
|
|
99
|
+
function parseBoolean(value, fallback) {
|
|
100
|
+
if (!value)
|
|
101
|
+
return fallback;
|
|
102
|
+
switch (value.toLowerCase()) {
|
|
103
|
+
case "1":
|
|
104
|
+
case "true":
|
|
105
|
+
case "yes":
|
|
106
|
+
case "on":
|
|
107
|
+
return true;
|
|
108
|
+
case "0":
|
|
109
|
+
case "false":
|
|
110
|
+
case "no":
|
|
111
|
+
case "off":
|
|
112
|
+
return false;
|
|
113
|
+
default:
|
|
114
|
+
throw new Error("Instructions S3 boolean setting is invalid");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validateBucket(bucket) {
|
|
118
|
+
if (bucket.length < 3 || bucket.length > 63 || !BUCKET_PATTERN.test(bucket) || bucket.includes("..") || bucket.includes(".-") || bucket.includes("-.") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket)) {
|
|
119
|
+
throw new Error("Instructions S3 bucket is invalid");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function validateRegion(region) {
|
|
123
|
+
if (!REGION_PATTERN.test(region))
|
|
124
|
+
throw new Error("Instructions AWS region is invalid");
|
|
125
|
+
}
|
|
126
|
+
function normalizeEndpoint(value) {
|
|
127
|
+
if (!value)
|
|
128
|
+
return;
|
|
129
|
+
let endpoint;
|
|
130
|
+
try {
|
|
131
|
+
endpoint = new URL(value);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error("Instructions S3 endpoint is invalid");
|
|
134
|
+
}
|
|
135
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
136
|
+
throw new Error("Instructions S3 endpoint must not contain credentials, query parameters, or fragments");
|
|
137
|
+
}
|
|
138
|
+
if (endpoint.pathname !== "/") {
|
|
139
|
+
throw new Error("Instructions S3 endpoint must be an origin without a path");
|
|
140
|
+
}
|
|
141
|
+
const localHttp = endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname);
|
|
142
|
+
if (endpoint.protocol !== "https:" && !localHttp) {
|
|
143
|
+
throw new Error("Instructions S3 endpoint must use HTTPS (HTTP is allowed only for loopback development)");
|
|
144
|
+
}
|
|
145
|
+
return endpoint.origin;
|
|
146
|
+
}
|
|
147
|
+
// src/storage/s3-object-store.ts
|
|
148
|
+
import { createHash, createHmac } from "crypto";
|
|
149
|
+
var CONTROL_CHARACTERS2 = /[\u0000-\u001f\u007f]/;
|
|
150
|
+
var VERSION_ID_PATTERN = /^[^\u0000-\u001f\u007f]{1,1024}$/;
|
|
151
|
+
var CONDITIONAL_CREATE_ATTEMPTS = 3;
|
|
152
|
+
var DEFAULT_EXISTS_DEADLINE_MS = 5000;
|
|
153
|
+
var DEFAULT_BYTES_DEADLINE_MS = 15000;
|
|
154
|
+
var DEFAULT_STAT_DEADLINE_MS = 5000;
|
|
155
|
+
var DEFAULT_VERSIONED_READ_DEADLINE_MS = 15000;
|
|
156
|
+
var DEFAULT_CONDITIONAL_CREATE_ATTEMPT_DEADLINE_MS = 15000;
|
|
157
|
+
var DEFAULT_EXISTENCE_RECONCILIATION_DEADLINE_MS = 5000;
|
|
158
|
+
var MAX_OPERATION_DEADLINE_MS = 60000;
|
|
159
|
+
var EMPTY_SHA256 = createHash("sha256").update("").digest("hex");
|
|
160
|
+
function resolveOperationDeadlineMs(value, fallback, label) {
|
|
161
|
+
const resolved = value ?? fallback;
|
|
162
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0 || resolved > MAX_OPERATION_DEADLINE_MS) {
|
|
163
|
+
throw new Error(`${label} must be an integer between 1 and ${MAX_OPERATION_DEADLINE_MS} milliseconds`);
|
|
164
|
+
}
|
|
165
|
+
return resolved;
|
|
166
|
+
}
|
|
167
|
+
async function runWithDeadline(operation, deadlineMs, onDeadline) {
|
|
168
|
+
let timer;
|
|
169
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
170
|
+
timer = setTimeout(() => {
|
|
171
|
+
try {
|
|
172
|
+
onDeadline?.();
|
|
173
|
+
} finally {
|
|
174
|
+
reject(new Error("Instructions S3 operation deadline exceeded"));
|
|
175
|
+
}
|
|
176
|
+
}, deadlineMs);
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
return await Promise.race([Promise.resolve().then(operation), deadline]);
|
|
180
|
+
} finally {
|
|
181
|
+
if (timer !== undefined)
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function assertSafeInstructionsObjectKey(key) {
|
|
186
|
+
if (!key || key.length > 1024 || key.startsWith("/") || key.endsWith("/") || key.includes("\\") || CONTROL_CHARACTERS2.test(key)) {
|
|
187
|
+
throw new Error("Instructions S3 object key is invalid");
|
|
188
|
+
}
|
|
189
|
+
for (const segment of key.split("/")) {
|
|
190
|
+
if (!segment || segment === "." || segment === "..") {
|
|
191
|
+
throw new Error("Instructions S3 object key contains an unsafe path segment");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function assertSafeInstructionsObjectVersionId(versionId) {
|
|
196
|
+
if (!VERSION_ID_PATTERN.test(versionId) || versionId.trim() !== versionId) {
|
|
197
|
+
throw new Error("Instructions S3 object version id is invalid");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function createInstructionsS3ObjectStore(config, createClient = (options) => new Bun.S3Client(options), requestFetch = fetch, deadlines = {}) {
|
|
201
|
+
const existsDeadlineMs = resolveOperationDeadlineMs(deadlines.existsDeadlineMs, DEFAULT_EXISTS_DEADLINE_MS, "Instructions S3 exists deadline");
|
|
202
|
+
const bytesDeadlineMs = resolveOperationDeadlineMs(deadlines.bytesDeadlineMs, DEFAULT_BYTES_DEADLINE_MS, "Instructions S3 bytes deadline");
|
|
203
|
+
const statDeadlineMs = resolveOperationDeadlineMs(deadlines.statDeadlineMs, DEFAULT_STAT_DEADLINE_MS, "Instructions S3 stat deadline");
|
|
204
|
+
const versionedReadDeadlineMs = resolveOperationDeadlineMs(deadlines.versionedReadDeadlineMs, DEFAULT_VERSIONED_READ_DEADLINE_MS, "Instructions S3 versioned read deadline");
|
|
205
|
+
const conditionalCreateAttemptDeadlineMs = resolveOperationDeadlineMs(deadlines.conditionalCreateAttemptDeadlineMs, DEFAULT_CONDITIONAL_CREATE_ATTEMPT_DEADLINE_MS, "Instructions S3 conditional create attempt deadline");
|
|
206
|
+
const existenceReconciliationDeadlineMs = resolveOperationDeadlineMs(deadlines.existenceReconciliationDeadlineMs, DEFAULT_EXISTENCE_RECONCILIATION_DEADLINE_MS, "Instructions S3 existence reconciliation deadline");
|
|
207
|
+
const client = createClient({
|
|
208
|
+
bucket: config.bucket,
|
|
209
|
+
region: config.region,
|
|
210
|
+
...config.endpoint ? { endpoint: config.endpoint } : {},
|
|
211
|
+
...config.credentials ? {
|
|
212
|
+
accessKeyId: config.credentials.accessKeyId,
|
|
213
|
+
secretAccessKey: config.credentials.secretAccessKey,
|
|
214
|
+
...config.credentials.sessionToken ? { sessionToken: config.credentials.sessionToken } : {}
|
|
215
|
+
} : {},
|
|
216
|
+
...config.forcePathStyle ? { virtualHostedStyle: false } : {}
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
async putIfAbsent(key, bytes, options) {
|
|
220
|
+
assertSafeInstructionsObjectKey(key);
|
|
221
|
+
for (let attempt = 1;attempt <= CONDITIONAL_CREATE_ATTEMPTS; attempt += 1) {
|
|
222
|
+
let response;
|
|
223
|
+
const controller = new AbortController;
|
|
224
|
+
try {
|
|
225
|
+
const url = client.presign(key, { method: "PUT", expiresIn: 60 });
|
|
226
|
+
response = await runWithDeadline(() => requestFetch(url, {
|
|
227
|
+
method: "PUT",
|
|
228
|
+
headers: {
|
|
229
|
+
"content-type": options.contentType,
|
|
230
|
+
"content-md5": createHash("md5").update(bytes).digest("base64"),
|
|
231
|
+
"if-none-match": "*"
|
|
232
|
+
},
|
|
233
|
+
body: Uint8Array.from(bytes),
|
|
234
|
+
redirect: "error",
|
|
235
|
+
signal: controller.signal
|
|
236
|
+
}), conditionalCreateAttemptDeadlineMs, () => controller.abort());
|
|
237
|
+
} catch {
|
|
238
|
+
let exists = false;
|
|
239
|
+
try {
|
|
240
|
+
exists = await runWithDeadline(() => client.file(key).exists(), existenceReconciliationDeadlineMs);
|
|
241
|
+
} catch {}
|
|
242
|
+
if (exists)
|
|
243
|
+
return { status: "existing" };
|
|
244
|
+
if (attempt < CONDITIONAL_CREATE_ATTEMPTS)
|
|
245
|
+
continue;
|
|
246
|
+
throw new Error("Instructions S3 conditional object creation failed");
|
|
247
|
+
}
|
|
248
|
+
if (response.ok) {
|
|
249
|
+
const versionId = cleanResponseVersionId(response.headers.get("x-amz-version-id"));
|
|
250
|
+
response.body?.cancel().catch(() => {
|
|
251
|
+
return;
|
|
252
|
+
});
|
|
253
|
+
return { status: "created", ...versionId ? { versionId } : {} };
|
|
254
|
+
}
|
|
255
|
+
response.body?.cancel().catch(() => {
|
|
256
|
+
return;
|
|
257
|
+
});
|
|
258
|
+
if (response.status === 412)
|
|
259
|
+
return { status: "existing" };
|
|
260
|
+
if (response.status === 409 && attempt < CONDITIONAL_CREATE_ATTEMPTS)
|
|
261
|
+
continue;
|
|
262
|
+
if (response.status === 409) {
|
|
263
|
+
throw new Error("Instructions S3 conditional object creation remained conflicted");
|
|
264
|
+
}
|
|
265
|
+
throw new Error(`Instructions S3 conditional object creation failed with status ${response.status}`);
|
|
266
|
+
}
|
|
267
|
+
throw new Error("Instructions S3 conditional object creation failed");
|
|
268
|
+
},
|
|
269
|
+
async get(key, options = {}) {
|
|
270
|
+
assertSafeInstructionsObjectKey(key);
|
|
271
|
+
if (options.versionId) {
|
|
272
|
+
return readExactVersion(config, key, options.versionId, "GET", requestFetch, versionedReadDeadlineMs).then((result) => result?.bytes);
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const file = client.file(key);
|
|
276
|
+
const exists = await runWithDeadline(() => file.exists(), existsDeadlineMs);
|
|
277
|
+
if (!exists)
|
|
278
|
+
return;
|
|
279
|
+
return Uint8Array.from(await runWithDeadline(() => file.bytes(), bytesDeadlineMs));
|
|
280
|
+
} catch {
|
|
281
|
+
throw new Error("Instructions S3 get operation failed");
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
async head(key, options = {}) {
|
|
285
|
+
assertSafeInstructionsObjectKey(key);
|
|
286
|
+
if (options.versionId) {
|
|
287
|
+
return readExactVersion(config, key, options.versionId, "HEAD", requestFetch, versionedReadDeadlineMs).then((result) => result?.metadata);
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const file = client.file(key);
|
|
291
|
+
const exists = await runWithDeadline(() => file.exists(), existsDeadlineMs);
|
|
292
|
+
if (!exists)
|
|
293
|
+
return;
|
|
294
|
+
const stat = await runWithDeadline(() => file.stat(), statDeadlineMs);
|
|
295
|
+
return {
|
|
296
|
+
size: stat.size,
|
|
297
|
+
...stat.etag ? { etag: stat.etag } : {},
|
|
298
|
+
...stat.type ? { contentType: stat.type } : {},
|
|
299
|
+
...stat.lastModified ? { lastModified: stat.lastModified } : {}
|
|
300
|
+
};
|
|
301
|
+
} catch {
|
|
302
|
+
throw new Error("Instructions S3 head operation failed");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async function readExactVersion(config, key, versionId, method, requestFetch, deadlineMs) {
|
|
308
|
+
assertSafeInstructionsObjectVersionId(versionId);
|
|
309
|
+
const credentials = resolveRequestCredentials(config);
|
|
310
|
+
const controller = new AbortController;
|
|
311
|
+
try {
|
|
312
|
+
const signed = signVersionedRequest(config, key, versionId, method, credentials, new Date);
|
|
313
|
+
const response = await runWithDeadline(() => requestFetch(signed.url, {
|
|
314
|
+
method,
|
|
315
|
+
headers: signed.headers,
|
|
316
|
+
redirect: "error",
|
|
317
|
+
signal: controller.signal
|
|
318
|
+
}), deadlineMs, () => controller.abort());
|
|
319
|
+
if (response.status === 404) {
|
|
320
|
+
response.body?.cancel().catch(() => {
|
|
321
|
+
return;
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
response.body?.cancel().catch(() => {
|
|
327
|
+
return;
|
|
328
|
+
});
|
|
329
|
+
throw new Error("versioned S3 request failed");
|
|
330
|
+
}
|
|
331
|
+
const observedVersionId = cleanResponseVersionId(response.headers.get("x-amz-version-id"));
|
|
332
|
+
if (observedVersionId !== versionId) {
|
|
333
|
+
response.body?.cancel().catch(() => {
|
|
334
|
+
return;
|
|
335
|
+
});
|
|
336
|
+
throw new Error("versioned S3 response authority mismatch");
|
|
337
|
+
}
|
|
338
|
+
const bytes = method === "GET" ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array;
|
|
339
|
+
const contentLength = response.headers.get("content-length");
|
|
340
|
+
const parsedSize = contentLength === null ? bytes.byteLength : Number(contentLength);
|
|
341
|
+
if (!Number.isSafeInteger(parsedSize) || parsedSize < 0)
|
|
342
|
+
throw new Error("invalid S3 content length");
|
|
343
|
+
const lastModifiedValue = response.headers.get("last-modified");
|
|
344
|
+
const lastModified = lastModifiedValue ? new Date(lastModifiedValue) : undefined;
|
|
345
|
+
if (lastModified && !Number.isFinite(lastModified.getTime()))
|
|
346
|
+
throw new Error("invalid S3 last modified timestamp");
|
|
347
|
+
return {
|
|
348
|
+
bytes,
|
|
349
|
+
metadata: {
|
|
350
|
+
size: parsedSize,
|
|
351
|
+
versionId: observedVersionId,
|
|
352
|
+
...response.headers.get("etag") ? { etag: response.headers.get("etag") } : {},
|
|
353
|
+
...response.headers.get("content-type") ? { contentType: response.headers.get("content-type") } : {},
|
|
354
|
+
...lastModified ? { lastModified } : {}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
} catch {
|
|
358
|
+
throw new Error(`Instructions S3 ${method === "GET" ? "get" : "head"} operation failed`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function resolveRequestCredentials(config) {
|
|
362
|
+
if (config.credentials)
|
|
363
|
+
return config.credentials;
|
|
364
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
|
|
365
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
|
366
|
+
const sessionToken = process.env.AWS_SESSION_TOKEN?.trim();
|
|
367
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
368
|
+
throw new Error("Instructions S3 exact-version reads require explicit or AWS environment credentials");
|
|
369
|
+
}
|
|
370
|
+
return { accessKeyId, secretAccessKey, ...sessionToken ? { sessionToken } : {} };
|
|
371
|
+
}
|
|
372
|
+
function signVersionedRequest(config, key, versionId, method, credentials, now) {
|
|
373
|
+
const url = buildObjectUrl(config, key);
|
|
374
|
+
url.searchParams.set("versionId", versionId);
|
|
375
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
376
|
+
const shortDate = amzDate.slice(0, 8);
|
|
377
|
+
const headers = new Headers({
|
|
378
|
+
host: url.host,
|
|
379
|
+
"x-amz-content-sha256": EMPTY_SHA256,
|
|
380
|
+
"x-amz-date": amzDate,
|
|
381
|
+
...credentials.sessionToken ? { "x-amz-security-token": credentials.sessionToken } : {}
|
|
382
|
+
});
|
|
383
|
+
const signedHeaderNames = [
|
|
384
|
+
"host",
|
|
385
|
+
"x-amz-content-sha256",
|
|
386
|
+
"x-amz-date",
|
|
387
|
+
...credentials.sessionToken ? ["x-amz-security-token"] : []
|
|
388
|
+
].sort();
|
|
389
|
+
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers.get(name).trim()}
|
|
390
|
+
`).join("");
|
|
391
|
+
const canonicalQuery = `versionId=${awsEncode(versionId)}`;
|
|
392
|
+
const canonicalRequest = [
|
|
393
|
+
method,
|
|
394
|
+
url.pathname,
|
|
395
|
+
canonicalQuery,
|
|
396
|
+
canonicalHeaders,
|
|
397
|
+
signedHeaderNames.join(";"),
|
|
398
|
+
EMPTY_SHA256
|
|
399
|
+
].join(`
|
|
400
|
+
`);
|
|
401
|
+
const scope = `${shortDate}/${config.region}/s3/aws4_request`;
|
|
402
|
+
const stringToSign = [
|
|
403
|
+
"AWS4-HMAC-SHA256",
|
|
404
|
+
amzDate,
|
|
405
|
+
scope,
|
|
406
|
+
createHash("sha256").update(canonicalRequest).digest("hex")
|
|
407
|
+
].join(`
|
|
408
|
+
`);
|
|
409
|
+
const dateKey = hmac(`AWS4${credentials.secretAccessKey}`, shortDate);
|
|
410
|
+
const regionKey = hmac(dateKey, config.region);
|
|
411
|
+
const serviceKey = hmac(regionKey, "s3");
|
|
412
|
+
const signingKey = hmac(serviceKey, "aws4_request");
|
|
413
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
414
|
+
headers.set("authorization", `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`);
|
|
415
|
+
return { url: url.toString(), headers };
|
|
416
|
+
}
|
|
417
|
+
function buildObjectUrl(config, key) {
|
|
418
|
+
const encodedKey = key.split("/").map(awsEncode).join("/");
|
|
419
|
+
if (config.endpoint) {
|
|
420
|
+
const endpoint = new URL(config.endpoint);
|
|
421
|
+
if (config.forcePathStyle)
|
|
422
|
+
endpoint.pathname = `/${awsEncode(config.bucket)}/${encodedKey}`;
|
|
423
|
+
else {
|
|
424
|
+
endpoint.hostname = `${config.bucket}.${endpoint.hostname}`;
|
|
425
|
+
endpoint.pathname = `/${encodedKey}`;
|
|
426
|
+
}
|
|
427
|
+
return endpoint;
|
|
428
|
+
}
|
|
429
|
+
return config.forcePathStyle ? new URL(`https://s3.${config.region}.amazonaws.com/${awsEncode(config.bucket)}/${encodedKey}`) : new URL(`https://${config.bucket}.s3.${config.region}.amazonaws.com/${encodedKey}`);
|
|
430
|
+
}
|
|
431
|
+
function awsEncode(value) {
|
|
432
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
433
|
+
}
|
|
434
|
+
function hmac(key, value) {
|
|
435
|
+
return createHmac("sha256", key).update(value).digest();
|
|
436
|
+
}
|
|
437
|
+
function cleanResponseVersionId(value) {
|
|
438
|
+
const versionId = value?.trim();
|
|
439
|
+
if (!versionId || versionId === "null")
|
|
440
|
+
return;
|
|
441
|
+
assertSafeInstructionsObjectVersionId(versionId);
|
|
442
|
+
return versionId;
|
|
443
|
+
}
|
|
444
|
+
function memoryInstructionsObjectStore() {
|
|
445
|
+
const objects = new Map;
|
|
446
|
+
let nextVersion = 1;
|
|
447
|
+
return {
|
|
448
|
+
async putIfAbsent(key, bytes, options) {
|
|
449
|
+
assertSafeInstructionsObjectKey(key);
|
|
450
|
+
const existing = objects.get(key);
|
|
451
|
+
if (existing)
|
|
452
|
+
return { status: "existing", versionId: existing.versionId };
|
|
453
|
+
const versionId = `memory-v${nextVersion++}`;
|
|
454
|
+
objects.set(key, {
|
|
455
|
+
bytes: Uint8Array.from(bytes),
|
|
456
|
+
contentType: options.contentType,
|
|
457
|
+
lastModified: new Date,
|
|
458
|
+
versionId
|
|
459
|
+
});
|
|
460
|
+
return { status: "created", versionId };
|
|
461
|
+
},
|
|
462
|
+
async get(key, options = {}) {
|
|
463
|
+
assertSafeInstructionsObjectKey(key);
|
|
464
|
+
if (options.versionId)
|
|
465
|
+
assertSafeInstructionsObjectVersionId(options.versionId);
|
|
466
|
+
const object = objects.get(key);
|
|
467
|
+
if (!object || options.versionId && options.versionId !== object.versionId)
|
|
468
|
+
return;
|
|
469
|
+
return Uint8Array.from(object.bytes);
|
|
470
|
+
},
|
|
471
|
+
async head(key, options = {}) {
|
|
472
|
+
assertSafeInstructionsObjectKey(key);
|
|
473
|
+
if (options.versionId)
|
|
474
|
+
assertSafeInstructionsObjectVersionId(options.versionId);
|
|
475
|
+
const object = objects.get(key);
|
|
476
|
+
return object && (!options.versionId || options.versionId === object.versionId) ? {
|
|
477
|
+
size: object.bytes.byteLength,
|
|
478
|
+
contentType: object.contentType,
|
|
479
|
+
lastModified: new Date(object.lastModified),
|
|
480
|
+
versionId: object.versionId
|
|
481
|
+
} : undefined;
|
|
482
|
+
},
|
|
483
|
+
keys() {
|
|
484
|
+
return [...objects.keys()].sort();
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// src/storage/s3-backup.ts
|
|
489
|
+
var INSTRUCTIONS_BACKUP_MANIFEST_SCHEMA = "hasna.instructions.backup-object/v1";
|
|
490
|
+
var encoder = new TextEncoder;
|
|
491
|
+
var decoder = new TextDecoder;
|
|
492
|
+
var SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
493
|
+
var CONTROL_CHARACTERS3 = /[\u0000-\u001f\u007f]/;
|
|
494
|
+
function buildInstructionsBackupKeys(prefix, backupId) {
|
|
495
|
+
const normalizedPrefix = normalizeInstructionsS3Prefix(prefix);
|
|
496
|
+
const encodedBackupId = encodeBackupId(backupId);
|
|
497
|
+
const rootKey = `${normalizedPrefix}backups/${encodedBackupId}`;
|
|
498
|
+
const keys = {
|
|
499
|
+
rootKey,
|
|
500
|
+
payloadKey: `${rootKey}/payload`,
|
|
501
|
+
manifestKey: `${rootKey}/manifest.json`
|
|
502
|
+
};
|
|
503
|
+
assertSafeInstructionsObjectKey(keys.payloadKey);
|
|
504
|
+
assertSafeInstructionsObjectKey(keys.manifestKey);
|
|
505
|
+
return keys;
|
|
506
|
+
}
|
|
507
|
+
async function planInstructionsBackupPush(input) {
|
|
508
|
+
const keys = buildInstructionsBackupKeys(input.prefix, input.backupId);
|
|
509
|
+
return {
|
|
510
|
+
operation: "push",
|
|
511
|
+
dryRun: true,
|
|
512
|
+
noNetwork: true,
|
|
513
|
+
backupId: input.backupId,
|
|
514
|
+
...keys,
|
|
515
|
+
sha256: sha256(input.bytes),
|
|
516
|
+
sizeBytes: input.bytes.byteLength,
|
|
517
|
+
contentType: normalizeContentType(input.contentType)
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async function pushInstructionsBackup(input) {
|
|
521
|
+
const plan = await planInstructionsBackupPush(input);
|
|
522
|
+
const existingManifestBytes = await input.store.get(plan.manifestKey);
|
|
523
|
+
if (existingManifestBytes) {
|
|
524
|
+
const existingManifest2 = parseManifest(existingManifestBytes, plan, input.backupId);
|
|
525
|
+
assertManifestMatchesPayload(existingManifest2, input.bytes);
|
|
526
|
+
const existingPayload2 = await input.store.get(existingManifest2.payloadKey);
|
|
527
|
+
if (!existingPayload2)
|
|
528
|
+
throw new Error("Instructions backup payload object is missing");
|
|
529
|
+
assertBytesMatch(existingPayload2, existingManifest2.sha256, existingManifest2.sizeBytes, "backup payload integrity");
|
|
530
|
+
return {
|
|
531
|
+
status: "existing",
|
|
532
|
+
manifest: existingManifest2,
|
|
533
|
+
...await resolveCurrentVersions(input.store, existingManifest2)
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const payloadCreate = await createImmutableObject(input.store, plan.payloadKey, input.bytes, plan.contentType);
|
|
537
|
+
if (payloadCreate.status === "existing") {
|
|
538
|
+
const existingPayload2 = await input.store.get(plan.payloadKey);
|
|
539
|
+
if (!existingPayload2)
|
|
540
|
+
throw new Error("Instructions backup payload object is missing");
|
|
541
|
+
assertBytesMatch(existingPayload2, plan.sha256, plan.sizeBytes, "existing immutable payload");
|
|
542
|
+
}
|
|
543
|
+
const manifest = {
|
|
544
|
+
schema: INSTRUCTIONS_BACKUP_MANIFEST_SCHEMA,
|
|
545
|
+
app: "instructions",
|
|
546
|
+
backupId: input.backupId,
|
|
547
|
+
payloadKey: plan.payloadKey,
|
|
548
|
+
manifestKey: plan.manifestKey,
|
|
549
|
+
sha256: plan.sha256,
|
|
550
|
+
sizeBytes: plan.sizeBytes,
|
|
551
|
+
contentType: plan.contentType,
|
|
552
|
+
createdAt: normalizeCreatedAt(input.createdAt)
|
|
553
|
+
};
|
|
554
|
+
const manifestCreate = await createImmutableObject(input.store, plan.manifestKey, serializeManifest(manifest), "application/json");
|
|
555
|
+
if (manifestCreate.status === "created") {
|
|
556
|
+
const versions = completeCreatedVersions(payloadCreate, manifestCreate);
|
|
557
|
+
return { status: "created", manifest, ...versions ? { versions } : {} };
|
|
558
|
+
}
|
|
559
|
+
const racedManifestBytes = await input.store.get(plan.manifestKey);
|
|
560
|
+
if (!racedManifestBytes)
|
|
561
|
+
throw new Error("Instructions backup manifest object is missing");
|
|
562
|
+
const existingManifest = parseManifest(racedManifestBytes, plan, input.backupId);
|
|
563
|
+
assertManifestMatchesPayload(existingManifest, input.bytes);
|
|
564
|
+
const existingPayload = await input.store.get(existingManifest.payloadKey);
|
|
565
|
+
if (!existingPayload)
|
|
566
|
+
throw new Error("Instructions backup payload object is missing");
|
|
567
|
+
assertBytesMatch(existingPayload, existingManifest.sha256, existingManifest.sizeBytes, "backup payload integrity");
|
|
568
|
+
return {
|
|
569
|
+
status: "existing",
|
|
570
|
+
manifest: existingManifest,
|
|
571
|
+
...await resolveCurrentVersions(input.store, existingManifest)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async function pullInstructionsBackup(input) {
|
|
575
|
+
const versions = normalizeReadVersions(input);
|
|
576
|
+
const keys = buildInstructionsBackupKeys(input.prefix, input.backupId);
|
|
577
|
+
const manifestBytes = await input.store.get(keys.manifestKey, versions ? { versionId: versions.manifestVersionId } : undefined);
|
|
578
|
+
if (!manifestBytes)
|
|
579
|
+
throw new Error("Instructions backup manifest object is missing");
|
|
580
|
+
const manifest = parseManifest(manifestBytes, keys, input.backupId);
|
|
581
|
+
const bytes = await input.store.get(manifest.payloadKey, versions ? { versionId: versions.payloadVersionId } : undefined);
|
|
582
|
+
if (!bytes)
|
|
583
|
+
throw new Error("Instructions backup payload object is missing");
|
|
584
|
+
assertBytesMatch(bytes, manifest.sha256, manifest.sizeBytes, "backup payload integrity");
|
|
585
|
+
if (versions) {
|
|
586
|
+
const [manifestMetadata, payloadMetadata] = await Promise.all([
|
|
587
|
+
input.store.head(keys.manifestKey, { versionId: versions.manifestVersionId }),
|
|
588
|
+
input.store.head(manifest.payloadKey, { versionId: versions.payloadVersionId })
|
|
589
|
+
]);
|
|
590
|
+
if (manifestMetadata?.versionId !== versions.manifestVersionId || payloadMetadata?.versionId !== versions.payloadVersionId) {
|
|
591
|
+
throw new Error("Instructions backup immutable version authority check failed");
|
|
592
|
+
}
|
|
593
|
+
if (payloadMetadata.size !== manifest.sizeBytes) {
|
|
594
|
+
throw new Error("Instructions backup payload version size check failed");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
manifest,
|
|
599
|
+
bytes,
|
|
600
|
+
versionPinned: Boolean(versions),
|
|
601
|
+
...versions ? { versions } : {}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
async function verifyInstructionsBackup(input) {
|
|
605
|
+
const result = await pullInstructionsBackup(input);
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
backupId: result.manifest.backupId,
|
|
609
|
+
payloadKey: result.manifest.payloadKey,
|
|
610
|
+
manifestKey: result.manifest.manifestKey,
|
|
611
|
+
sha256: result.manifest.sha256,
|
|
612
|
+
sizeBytes: result.manifest.sizeBytes,
|
|
613
|
+
versionPinned: result.versionPinned,
|
|
614
|
+
...result.versions ? { versions: result.versions } : {}
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
async function createImmutableObject(store, key, bytes, contentType) {
|
|
618
|
+
if (!store.putIfAbsent) {
|
|
619
|
+
throw new Error("Instructions object store does not support atomic immutable creation");
|
|
620
|
+
}
|
|
621
|
+
return store.putIfAbsent(key, bytes, { contentType });
|
|
622
|
+
}
|
|
623
|
+
function completeCreatedVersions(payload, manifest) {
|
|
624
|
+
if (!payload.versionId || !manifest.versionId)
|
|
625
|
+
return;
|
|
626
|
+
return { payloadVersionId: payload.versionId, manifestVersionId: manifest.versionId };
|
|
627
|
+
}
|
|
628
|
+
async function resolveCurrentVersions(store, manifest) {
|
|
629
|
+
const [payload, manifestObject] = await Promise.all([
|
|
630
|
+
store.head(manifest.payloadKey),
|
|
631
|
+
store.head(manifest.manifestKey)
|
|
632
|
+
]);
|
|
633
|
+
if (!payload?.versionId || !manifestObject?.versionId)
|
|
634
|
+
return {};
|
|
635
|
+
return {
|
|
636
|
+
versions: {
|
|
637
|
+
payloadVersionId: payload.versionId,
|
|
638
|
+
manifestVersionId: manifestObject.versionId
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function normalizeReadVersions(input) {
|
|
643
|
+
const payloadVersionId = input.payloadVersionId?.trim();
|
|
644
|
+
const manifestVersionId = input.manifestVersionId?.trim();
|
|
645
|
+
if (Boolean(payloadVersionId) !== Boolean(manifestVersionId)) {
|
|
646
|
+
throw new Error("Instructions backup payload and manifest version ids must be provided together");
|
|
647
|
+
}
|
|
648
|
+
if (!payloadVersionId || !manifestVersionId)
|
|
649
|
+
return;
|
|
650
|
+
assertSafeInstructionsObjectVersionId(payloadVersionId);
|
|
651
|
+
assertSafeInstructionsObjectVersionId(manifestVersionId);
|
|
652
|
+
return { payloadVersionId, manifestVersionId };
|
|
653
|
+
}
|
|
654
|
+
function encodeBackupId(backupId) {
|
|
655
|
+
const trimmed = backupId.trim();
|
|
656
|
+
if (!trimmed || trimmed.length > 128 || trimmed === "." || trimmed === ".." || trimmed.includes("\\") || CONTROL_CHARACTERS3.test(trimmed)) {
|
|
657
|
+
throw new Error("Instructions backup id is invalid");
|
|
658
|
+
}
|
|
659
|
+
return encodeURIComponent(trimmed);
|
|
660
|
+
}
|
|
661
|
+
function normalizeContentType(value) {
|
|
662
|
+
const contentType = value?.trim() || "application/octet-stream";
|
|
663
|
+
if (contentType.length > 255 || CONTROL_CHARACTERS3.test(contentType)) {
|
|
664
|
+
throw new Error("Instructions backup content type is invalid");
|
|
665
|
+
}
|
|
666
|
+
return contentType;
|
|
667
|
+
}
|
|
668
|
+
function normalizeCreatedAt(value) {
|
|
669
|
+
const date = value ?? new Date;
|
|
670
|
+
if (!Number.isFinite(date.getTime()))
|
|
671
|
+
throw new Error("Instructions backup creation timestamp is invalid");
|
|
672
|
+
return date.toISOString();
|
|
673
|
+
}
|
|
674
|
+
function serializeManifest(manifest) {
|
|
675
|
+
return encoder.encode(`${JSON.stringify(manifest, null, 2)}
|
|
676
|
+
`);
|
|
677
|
+
}
|
|
678
|
+
function parseManifest(bytes, keys, backupId) {
|
|
679
|
+
let value;
|
|
680
|
+
try {
|
|
681
|
+
value = JSON.parse(decoder.decode(bytes));
|
|
682
|
+
} catch {
|
|
683
|
+
throw new Error("Instructions backup manifest is not valid JSON");
|
|
684
|
+
}
|
|
685
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
686
|
+
throw new Error("Instructions backup manifest is invalid");
|
|
687
|
+
}
|
|
688
|
+
const manifest = value;
|
|
689
|
+
if (manifest.schema !== INSTRUCTIONS_BACKUP_MANIFEST_SCHEMA || manifest.app !== "instructions" || manifest.backupId !== backupId || manifest.payloadKey !== keys.payloadKey || manifest.manifestKey !== keys.manifestKey || typeof manifest.sha256 !== "string" || !SHA256_PATTERN.test(manifest.sha256) || typeof manifest.sizeBytes !== "number" || !Number.isSafeInteger(manifest.sizeBytes) || manifest.sizeBytes < 0 || typeof manifest.contentType !== "string" || !manifest.contentType || typeof manifest.createdAt !== "string" || !Number.isFinite(Date.parse(manifest.createdAt))) {
|
|
690
|
+
throw new Error("Instructions backup manifest failed validation");
|
|
691
|
+
}
|
|
692
|
+
assertSafeInstructionsObjectKey(manifest.payloadKey);
|
|
693
|
+
assertSafeInstructionsObjectKey(manifest.manifestKey);
|
|
694
|
+
return manifest;
|
|
695
|
+
}
|
|
696
|
+
function assertManifestMatchesPayload(manifest, bytes) {
|
|
697
|
+
const digest = sha256(bytes);
|
|
698
|
+
if (manifest.sha256 !== digest || manifest.sizeBytes !== bytes.byteLength) {
|
|
699
|
+
throw new Error("Instructions backup is immutable; the existing backup id refers to different bytes");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function assertBytesMatch(bytes, expectedSha256, expectedSize, label) {
|
|
703
|
+
if (bytes.byteLength !== expectedSize || sha256(bytes) !== expectedSha256) {
|
|
704
|
+
throw new Error(`Instructions ${label} check failed`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function sha256(bytes) {
|
|
708
|
+
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
709
|
+
}
|
|
710
|
+
export {
|
|
711
|
+
verifyInstructionsBackup,
|
|
712
|
+
pushInstructionsBackup,
|
|
713
|
+
pullInstructionsBackup,
|
|
714
|
+
planInstructionsBackupPush,
|
|
715
|
+
normalizeInstructionsS3Prefix,
|
|
716
|
+
memoryInstructionsObjectStore,
|
|
717
|
+
loadInstructionsS3Config,
|
|
718
|
+
createInstructionsS3ObjectStore,
|
|
719
|
+
buildInstructionsBackupKeys,
|
|
720
|
+
assertSafeInstructionsObjectVersionId,
|
|
721
|
+
assertSafeInstructionsObjectKey,
|
|
722
|
+
INSTRUCTIONS_S3_ENV,
|
|
723
|
+
INSTRUCTIONS_S3_ALIAS_ENV,
|
|
724
|
+
INSTRUCTIONS_BACKUP_MANIFEST_SCHEMA
|
|
725
|
+
};
|