@deployfoundation/foundation-deploy 0.1.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.
Files changed (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The build context a Lambda bundle is produced from: the Foundation
3
+ * workspace, and nothing else.
4
+ *
5
+ * It is a copy rather than the checkout itself so the Docker builder never
6
+ * receives a developer's repository root, where ignored local credential files
7
+ * may sit. Symlinks are rejected outright rather than followed, because a
8
+ * symlink is how something outside the workspace would get into the image.
9
+ */
10
+ import { copyFileSync, cpSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ const contextByRoot = new Map<string, string>();
15
+ const IGNORED_DIRECTORIES: Record<string, true> = {
16
+ ".git": true,
17
+ "cdk.out": true,
18
+ dist: true,
19
+ node_modules: true,
20
+ };
21
+
22
+ /** Root files a frozen workspace install needs; every one must exist. */
23
+ const WORKSPACE_FILES = ["package.json", "bun.lock", "bunfig.toml", "tsconfig.json"] as const;
24
+
25
+ function copySourceTree(source: string, destination: string): void {
26
+ cpSync(source, destination, {
27
+ recursive: true,
28
+ filter(candidate) {
29
+ const relative = path.relative(source, candidate);
30
+ if (relative === "") return true;
31
+ if (relative.split(path.sep).some((component) => component in IGNORED_DIRECTORIES))
32
+ return false;
33
+ if (lstatSync(candidate).isSymbolicLink())
34
+ throw new Error(`Lambda bundle input contains a symlink: ${candidate}`);
35
+ return true;
36
+ },
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Build the minimal workspace required for frozen-install Lambda bundling and
42
+ * return its path. Cached per root: one synth bundles several entry points out
43
+ * of the same tree, and copying it once per Lambda would dominate the run.
44
+ */
45
+ export function lambdaBundleContext(workspaceRoot: string): string {
46
+ const existing = contextByRoot.get(workspaceRoot);
47
+ if (existing !== undefined) return existing;
48
+
49
+ const context = mkdtempSync(path.join(tmpdir(), "foundation-lambda-bundle-"));
50
+ try {
51
+ for (const file of WORKSPACE_FILES)
52
+ copyFileSync(path.join(workspaceRoot, file), path.join(context, file));
53
+ // Every workspace, because `bun install --frozen-lockfile` refuses a
54
+ // workspace the lockfile names and the disk lacks — and because a proxy
55
+ // bundle pulls its capability package, which pulls core and connectors.
56
+ copySourceTree(path.join(workspaceRoot, "packages"), path.join(context, "packages"));
57
+ } catch (error) {
58
+ rmSync(context, { recursive: true, force: true });
59
+ throw error;
60
+ }
61
+
62
+ contextByRoot.set(workspaceRoot, context);
63
+ return context;
64
+ }
package/src/names.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Names for the CDK app, and how it finds the deployment it is synthesizing.
3
+ *
4
+ * Nothing here identifies a deployment: `namesFor(instance)` derives every
5
+ * stack, secret, role, gateway and ECR name from the instance file the caller
6
+ * names, and the deploy tool imports the same function from core so the two
7
+ * cannot drift.
8
+ *
9
+ * The instance file is a PATH the caller supplies (`-c instanceFile=…`), not a
10
+ * name looked up in an `instances/` directory this package does not have.
11
+ * Foundation ships no instance file and no company configuration; an instance
12
+ * repository owns both, and its `config:` key resolves relative to the instance
13
+ * file's own directory rather than to a repository root.
14
+ *
15
+ * What remains below are genuine constants — strings that are the same for
16
+ * every deployment because they name an AWS-side thing.
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, isAbsolute, resolve } from "node:path";
20
+ import { type CapabilityId, INTEGRATION_CAPABILITY_IDS } from "@deployfoundation/foundation-core";
21
+ import {
22
+ type Instance,
23
+ type InstanceNames,
24
+ instanceNames,
25
+ parseInstance,
26
+ } from "@deployfoundation/foundation-core/instance";
27
+ import { parse as parseYaml } from "yaml";
28
+
29
+ export { instanceNames as namesFor };
30
+ export type { Instance, InstanceNames };
31
+
32
+ /**
33
+ * One instance file, parsed, plus the paths everything else resolves against.
34
+ *
35
+ * Stacks take `instance` and, where they read runtime configuration at synth,
36
+ * `configPath`. Both travel explicitly rather than through a module-level repo
37
+ * root, because there is no repository root to assume: the file may sit
38
+ * anywhere the deployer's checkout puts it.
39
+ */
40
+ export interface InstanceFile {
41
+ instance: Instance;
42
+ /** Absolute path of the instance YAML itself. */
43
+ path: string;
44
+ /** Absolute path of the runtime config `instance.config` names. */
45
+ configPath: string;
46
+ }
47
+
48
+ /**
49
+ * Read and validate the instance file at `path`, absolute or relative to
50
+ * `cwd`. A plain file read — the CDK app must synthesize with no AWS
51
+ * credentials and no network.
52
+ *
53
+ * Unlike the sky-tags version this does not check the parsed `name` against a
54
+ * filename: the file no longer has to be called anything in particular, and
55
+ * the name is one of the values it declares rather than the key it was found
56
+ * under.
57
+ */
58
+ export function loadInstanceFile(path: string, cwd: string = process.cwd()): InstanceFile {
59
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path);
60
+ let text: string;
61
+ try {
62
+ text = readFileSync(absolute, "utf8");
63
+ } catch {
64
+ throw new Error(`no instance file at ${absolute}`);
65
+ }
66
+ const instance = parseInstance(text);
67
+ return { instance, path: absolute, configPath: configPathFor(instance, absolute) };
68
+ }
69
+
70
+ /**
71
+ * Where an instance's runtime config lives: `config:` resolved against the
72
+ * DIRECTORY OF THE INSTANCE FILE. An instance repository holding
73
+ * `instance.yaml` and `config.yaml` side by side therefore writes
74
+ * `config: config.yaml`, and moving the pair somewhere else needs no edit.
75
+ */
76
+ export function configPathFor(instance: Instance, instanceFilePath: string): string {
77
+ return isAbsolute(instance.config)
78
+ ? instance.config
79
+ : resolve(dirname(instanceFilePath), instance.config);
80
+ }
81
+
82
+ /** The runtime config document, read at synth. Never cached across paths. */
83
+ function configDocument(configPath: string): Record<string, unknown> {
84
+ const doc = parseYaml(readFileSync(configPath, "utf8")) as Record<string, unknown> | null;
85
+ return doc ?? {};
86
+ }
87
+
88
+ /**
89
+ * Is `capability` switched ON in the instance's runtime config?
90
+ *
91
+ * Read at synth the same way {@link adminsFor} reads `admins`: the CDK app
92
+ * must synthesize with no AWS credentials and no network, so the file on disk
93
+ * is the only source. Defaults to `false`, matching the schema default for
94
+ * every opt-in capability — this is not the place to learn a capability's
95
+ * default, only whether an instance asked for it.
96
+ *
97
+ * Use it ONLY for a capability that needs infrastructure of its own (Mongo's
98
+ * 27017 egress). That makes turning such a capability ON a deploy rather than
99
+ * a config sync; turning one OFF still removes its tools on the next turn.
100
+ */
101
+ export function capabilityEnabled(configPath: string, capability: CapabilityId): boolean {
102
+ const doc = configDocument(configPath) as {
103
+ capabilities?: Record<string, { enabled?: unknown } | undefined>;
104
+ };
105
+ const enabled = doc.capabilities?.[capability]?.enabled;
106
+ if (enabled !== undefined && typeof enabled !== "boolean")
107
+ throw new Error(`${configPath}: capabilities.${capability}.enabled must be a boolean`);
108
+ return enabled ?? false;
109
+ }
110
+
111
+ /**
112
+ * Whether normal queue turns must be gateway-signed before the invoker accepts
113
+ * them.
114
+ *
115
+ * The switch is the capability registry, not a hand-kept list: an integration
116
+ * capability is one whose infrastructure the instance file opts into, and
117
+ * every one of them mints actor capabilities the agent must not be able to
118
+ * forge. A new integration capability is therefore covered by its registry
119
+ * entry alone.
120
+ */
121
+ export function requiresAuthenticatedQueue(instance: Instance): boolean {
122
+ return enabledIntegrations(instance).length > 0;
123
+ }
124
+
125
+ /**
126
+ * The integration capabilities this instance opted into, from the registry.
127
+ * `INTEGRATION_CAPABILITY_IDS` is the source of truth for which capabilities
128
+ * have an `integrations.<id>` switch at all.
129
+ */
130
+ export function enabledIntegrations(instance: Instance): CapabilityId[] {
131
+ const integrations = instance.integrations as Record<string, boolean | undefined>;
132
+ return INTEGRATION_CAPABILITY_IDS.filter((id) => integrations[id] === true);
133
+ }
134
+
135
+ /**
136
+ * Does this instance provision `id`'s durable infrastructure?
137
+ *
138
+ * Every per-capability branch in the stacks asks this rather than reading
139
+ * `instance.integrations.<id>` directly, so the registry's `integration` flag
140
+ * is visibly what decides. An id with no registry entry, or one whose entry
141
+ * carries no `integration` flag, is a programming error rather than a silent
142
+ * `false`: it would otherwise quietly stop provisioning a capability's IAM.
143
+ */
144
+ export function provisionsIntegration(instance: Instance, id: CapabilityId): boolean {
145
+ if (!INTEGRATION_CAPABILITY_IDS.includes(id))
146
+ throw new Error(`${id} is not an integration capability in the registry`);
147
+ return (instance.integrations as Record<string, boolean | undefined>)[id] === true;
148
+ }
149
+
150
+ /**
151
+ * The instance's admin Slack user ids, as the CSV the gateway Lambda expects.
152
+ *
153
+ * `-c admins=…` overrides this; without it a bare `cdk synth` still produces
154
+ * the same list the running deployment has, because it comes from the very
155
+ * runtime config the agent reads at session start. No id is written down here.
156
+ */
157
+ export function adminsFor(configPath: string): string {
158
+ const doc = configDocument(configPath) as { admins?: unknown };
159
+ const admins = doc.admins;
160
+ if (admins === undefined || admins === null) return "";
161
+ if (!Array.isArray(admins) || admins.some((id) => typeof id !== "string"))
162
+ throw new Error(`${configPath}: admins must be a list of Slack user ids`);
163
+ return (admins as string[]).join(",");
164
+ }
165
+
166
+ /** Target name; the MCP tool is exposed as `<target>___<tool>`. */
167
+ export const WEB_SEARCH_TARGET = "websearch";
168
+ export const WEB_SEARCH_CONNECTOR_VERSION = "1.2.0";
169
+ /** The runtime endpoint humans talk to; the pipeline promotes versions onto it after smoke tests. */
170
+ export const LIVE_ENDPOINT_NAME = "live";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The real KMS client behind manifest signing and verification.
3
+ *
4
+ * Kept apart from `manifest.ts` so that module stays pure: a CDK synth reads
5
+ * manifests and must not drag an AWS client into the synth, and the manifest
6
+ * tests must not need one either.
7
+ *
8
+ * The key lives in the Foundation release account and the REGION comes from
9
+ * its ARN, not from the deployment: a customer deploying into `eu-west-1`
10
+ * still verifies against the key where it was created. Its key policy is what
11
+ * lets them — `kms:Verify` and `kms:GetPublicKey` for the registered customer
12
+ * accounts, exactly as the ECR repository policy lets them pull the image
13
+ * (`release-account-stack.ts`).
14
+ */
15
+ import { KMSClient, SignCommand, VerifyCommand } from "@aws-sdk/client-kms";
16
+ import {
17
+ type KmsVerifier,
18
+ type ReleaseManifest,
19
+ type ReleaseSignature,
20
+ type SigningAlgorithm,
21
+ manifestSigningPayload,
22
+ } from "./manifest.ts";
23
+
24
+ /** Default signing algorithm for a new Foundation release key (RSA 4096). */
25
+ export const DEFAULT_SIGNING_ALGORITHM: SigningAlgorithm = "RSASSA_PSS_SHA_256";
26
+
27
+ /** `arn:aws:kms:<region>:<account>:key/<id>` → `<region>`. */
28
+ export function regionOfKeyArn(keyArn: string): string {
29
+ const region = keyArn.split(":")[3];
30
+ if (region === undefined || region === "")
31
+ throw new Error(`not a KMS key ARN: ${keyArn} (expected arn:aws:kms:<region>:<account>:key/…)`);
32
+ return region;
33
+ }
34
+
35
+ export interface KmsOptions {
36
+ /** Named AWS profile, or `""`/omitted for the ambient credential chain. */
37
+ profile?: string;
38
+ }
39
+
40
+ function client(keyArn: string, options: KmsOptions = {}): KMSClient {
41
+ const profile = options.profile ?? "";
42
+ return new KMSClient({
43
+ region: regionOfKeyArn(keyArn),
44
+ ...(profile === "" ? {} : { profile }),
45
+ });
46
+ }
47
+
48
+ /** A {@link KmsVerifier} backed by the AWS SDK. */
49
+ export function kmsVerifier(keyArn: string, options: KmsOptions = {}): KmsVerifier {
50
+ const kms = client(keyArn, options);
51
+ return {
52
+ async verify(input) {
53
+ const result = await kms.send(new VerifyCommand(input));
54
+ return {
55
+ ...(result.SignatureValid === undefined ? {} : { SignatureValid: result.SignatureValid }),
56
+ };
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Sign a freshly built manifest. Only the release workflow calls this, with
63
+ * the Foundation release role's credentials; a deployer never signs anything.
64
+ */
65
+ export async function signManifest(
66
+ manifest: ReleaseManifest,
67
+ keyArn: string,
68
+ options: KmsOptions & { algorithm?: SigningAlgorithm } = {},
69
+ ): Promise<ReleaseSignature> {
70
+ const algorithm = options.algorithm ?? DEFAULT_SIGNING_ALGORITHM;
71
+ const kms = client(keyArn, options);
72
+ const result = await kms.send(
73
+ new SignCommand({
74
+ KeyId: keyArn,
75
+ Message: manifestSigningPayload(manifest),
76
+ MessageType: "RAW",
77
+ SigningAlgorithm: algorithm,
78
+ }),
79
+ );
80
+ if (result.Signature === undefined) throw new Error(`KMS returned no signature for ${keyArn}`);
81
+ return {
82
+ kmsKeyArn: keyArn,
83
+ algorithm,
84
+ value: Buffer.from(result.Signature).toString("base64"),
85
+ };
86
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The release manifest: what `vX.Y.Z` *is*, and how a deployer proves it.
3
+ *
4
+ * Foundation publishes one immutable release per tag — an agent image in
5
+ * Foundation's own ECR, one Lambda bundle per entry point in the release
6
+ * bucket, and the product skills tarball — and this document is the list of
7
+ * their digests, signed with a KMS key in the Foundation account. It replaces
8
+ * `foundation.lock.json` and the tree-hash gate: pinning is the version, and
9
+ * integrity is the signature over these digests.
10
+ *
11
+ * Deliberately pure. Nothing here talks to AWS or to the network: the builder
12
+ * hashes files on disk, and {@link verifyManifest} takes the KMS verifier and
13
+ * the artifact reader as arguments. `src/release/kms.ts` supplies the real
14
+ * ones; a test supplies fakes.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { readFileSync, statSync } from "node:fs";
18
+ import { z } from "zod";
19
+
20
+ /** `vMAJOR.MINOR.PATCH` — the Git tag a release is built from, verbatim. */
21
+ export const RELEASE_VERSION_RE = /^v\d+\.\d+\.\d+$/;
22
+
23
+ const Sha256 = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256");
24
+
25
+ const ReleaseArtifactSchema = z
26
+ .object({
27
+ /** Key inside the release bucket, e.g. `releases/v0.1.0/lambda/gateway.zip`. */
28
+ key: z.string().min(1),
29
+ sha256: Sha256,
30
+ bytes: z.number().int().nonnegative(),
31
+ })
32
+ .strict();
33
+
34
+ /**
35
+ * How a manifest may be signed. Only asymmetric algorithms KMS offers for
36
+ * `Verify`, listed explicitly so a tampered manifest cannot name a weaker one
37
+ * — the signature is checked against the algorithm the *manifest* declares,
38
+ * so that field has to be constrained.
39
+ */
40
+ export const SIGNING_ALGORITHMS = [
41
+ "RSASSA_PSS_SHA_256",
42
+ "RSASSA_PSS_SHA_384",
43
+ "RSASSA_PSS_SHA_512",
44
+ "RSASSA_PKCS1_V1_5_SHA_256",
45
+ "RSASSA_PKCS1_V1_5_SHA_384",
46
+ "RSASSA_PKCS1_V1_5_SHA_512",
47
+ "ECDSA_SHA_256",
48
+ "ECDSA_SHA_384",
49
+ "ECDSA_SHA_512",
50
+ ] as const;
51
+
52
+ export type SigningAlgorithm = (typeof SIGNING_ALGORITHMS)[number];
53
+
54
+ const ReleaseManifestSchema = z
55
+ .object({
56
+ version: z.string().regex(RELEASE_VERSION_RE, "must be vMAJOR.MINOR.PATCH"),
57
+ /** The Foundation commit the release was built from. */
58
+ gitCommit: z.string().regex(/^[0-9a-f]{40}$/, "must be a full commit sha"),
59
+ createdAt: z.string().datetime(),
60
+ agentImage: z
61
+ .object({
62
+ /** ECR repository URI without a tag: `<account>.dkr.ecr.<region>.amazonaws.com/foundation/agent`. */
63
+ repository: z.string().min(1),
64
+ tag: z.string().min(1),
65
+ /** The manifest digest the runtime is pinned to. */
66
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
67
+ })
68
+ .strict(),
69
+ /** One entry per `LAMBDA_ENTRY_POINTS` id. */
70
+ lambda: z.record(ReleaseArtifactSchema),
71
+ /** `skills.tar.gz`; no `bytes`, because nothing streams it in pieces. */
72
+ skills: z.object({ key: z.string().min(1), sha256: Sha256 }).strict(),
73
+ /**
74
+ * Absent only in the moment between building and signing. A deploy
75
+ * refuses an unsigned manifest — see {@link verifyManifest}.
76
+ */
77
+ signature: z
78
+ .object({
79
+ kmsKeyArn: z.string().min(1),
80
+ algorithm: z.enum(SIGNING_ALGORITHMS),
81
+ /** Base64 of the raw KMS signature. */
82
+ value: z.string().min(1),
83
+ })
84
+ .strict()
85
+ .optional(),
86
+ })
87
+ .strict();
88
+
89
+ export type ReleaseArtifact = z.infer<typeof ReleaseArtifactSchema>;
90
+ export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
91
+ export type ReleaseSignature = NonNullable<ReleaseManifest["signature"]>;
92
+
93
+ /** Parse and validate a manifest document. Throws on any schema violation. */
94
+ export function parseManifest(json: string | unknown): ReleaseManifest {
95
+ return ReleaseManifestSchema.parse(typeof json === "string" ? JSON.parse(json) : json);
96
+ }
97
+
98
+ /** Where a release's files live in the release bucket. One layout, one place. */
99
+ export function releasePrefix(version: string): string {
100
+ return `releases/${version}`;
101
+ }
102
+
103
+ export function manifestKey(version: string): string {
104
+ return `${releasePrefix(version)}/manifest.json`;
105
+ }
106
+
107
+ export function lambdaKey(version: string, id: string): string {
108
+ return `${releasePrefix(version)}/lambda/${id}.zip`;
109
+ }
110
+
111
+ export function skillsKey(version: string): string {
112
+ return `${releasePrefix(version)}/skills.tar.gz`;
113
+ }
114
+
115
+ /** Lowercase hex sha256 of some bytes. */
116
+ export function sha256Hex(bytes: Uint8Array | string): string {
117
+ return createHash("sha256").update(bytes).digest("hex");
118
+ }
119
+
120
+ /** Lowercase hex sha256 of a file, read whole: release artifacts are megabytes, not gigabytes. */
121
+ export function sha256File(path: string): string {
122
+ return sha256Hex(readFileSync(path));
123
+ }
124
+
125
+ export interface BuildManifestInput {
126
+ version: string;
127
+ gitCommit: string;
128
+ /** Defaults to now; pinned by the tests so a manifest is reproducible. */
129
+ createdAt?: string;
130
+ agentImage: ReleaseManifest["agentImage"];
131
+ /** Lambda id → the built `.zip` on disk. The ids become the manifest's keys. */
132
+ lambda: Record<string, string>;
133
+ /** The product skills tarball on disk. */
134
+ skills: string;
135
+ }
136
+
137
+ /**
138
+ * Hash what was built and describe it. The result is unsigned: signing is a
139
+ * separate step because it needs AWS and this does not.
140
+ */
141
+ export function buildManifest(input: BuildManifestInput): ReleaseManifest {
142
+ const lambda: Record<string, ReleaseArtifact> = {};
143
+ for (const [id, path] of Object.entries(input.lambda).sort(([a], [b]) => (a < b ? -1 : 1)))
144
+ lambda[id] = {
145
+ key: lambdaKey(input.version, id),
146
+ sha256: sha256File(path),
147
+ bytes: statSync(path).size,
148
+ };
149
+ return parseManifest({
150
+ version: input.version,
151
+ gitCommit: input.gitCommit,
152
+ createdAt: input.createdAt ?? new Date().toISOString(),
153
+ agentImage: input.agentImage,
154
+ lambda,
155
+ skills: { key: skillsKey(input.version), sha256: sha256File(input.skills) },
156
+ });
157
+ }
158
+
159
+ /**
160
+ * The exact bytes a signature covers: the manifest without its `signature`,
161
+ * serialized canonically (object keys sorted, no insignificant whitespace).
162
+ *
163
+ * Canonical because the signer and the verifier are different programs on
164
+ * different machines, and `JSON.stringify` preserves insertion order — a
165
+ * round trip through S3 must not be able to change one byte of what was
166
+ * signed.
167
+ */
168
+ export function manifestSigningPayload(manifest: ReleaseManifest): Uint8Array {
169
+ const { signature: _signature, ...unsigned } = manifest;
170
+ return new TextEncoder().encode(canonicalJson(unsigned));
171
+ }
172
+
173
+ /** JSON with every object's keys in sorted order. Arrays keep their order. */
174
+ export function canonicalJson(value: unknown): string {
175
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
176
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
177
+ const entries = Object.entries(value as Record<string, unknown>)
178
+ .filter(([, v]) => v !== undefined)
179
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
180
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
181
+ }
182
+
183
+ /** What {@link verifyManifest} needs of a KMS client: one `Verify` call. */
184
+ export interface KmsVerifier {
185
+ verify(input: {
186
+ KeyId: string;
187
+ Message: Uint8Array;
188
+ MessageType: "RAW";
189
+ Signature: Uint8Array;
190
+ SigningAlgorithm: SigningAlgorithm;
191
+ }): Promise<{ SignatureValid?: boolean }>;
192
+ }
193
+
194
+ export interface VerifyManifestOptions {
195
+ /** The key the deployer expects. A manifest naming another key is rejected. */
196
+ kmsKeyArn: string;
197
+ kms: KmsVerifier;
198
+ /**
199
+ * Reads one artifact's bytes by its manifest key. Omitted, only the
200
+ * signature is checked — which is what a `--dry-run` wants and what a
201
+ * deploy must NOT settle for.
202
+ */
203
+ readArtifact?: (key: string) => Promise<Uint8Array>;
204
+ /** Refuse a manifest that is not this version (the version the caller asked for). */
205
+ expectVersion?: string;
206
+ }
207
+
208
+ /**
209
+ * Prove a manifest is Foundation's and that the bytes behind it are the ones
210
+ * it names. Throws with a specific message on the first failure; returns the
211
+ * parsed manifest.
212
+ *
213
+ * Order matters and is the point of this function: the signature is checked
214
+ * BEFORE anything is downloaded or deployed, because every other value in the
215
+ * document — the image digest included — is only as trustworthy as it is.
216
+ */
217
+ export async function verifyManifest(
218
+ manifest: ReleaseManifest | unknown,
219
+ options: VerifyManifestOptions,
220
+ ): Promise<ReleaseManifest> {
221
+ const parsed = parseManifest(manifest);
222
+ if (options.expectVersion !== undefined && parsed.version !== options.expectVersion)
223
+ throw new Error(
224
+ `release manifest is ${parsed.version}, not the requested ${options.expectVersion}`,
225
+ );
226
+ const signature = parsed.signature;
227
+ if (signature === undefined)
228
+ throw new Error(`release ${parsed.version} carries an unsigned manifest; refusing to deploy`);
229
+ if (signature.kmsKeyArn !== options.kmsKeyArn)
230
+ throw new Error(
231
+ `release ${parsed.version} is signed by ${signature.kmsKeyArn}, not the expected ${options.kmsKeyArn}`,
232
+ );
233
+ const result = await options.kms.verify({
234
+ KeyId: options.kmsKeyArn,
235
+ Message: manifestSigningPayload(parsed),
236
+ MessageType: "RAW",
237
+ Signature: Uint8Array.from(Buffer.from(signature.value, "base64")),
238
+ SigningAlgorithm: signature.algorithm,
239
+ });
240
+ if (result.SignatureValid !== true)
241
+ throw new Error(`release ${parsed.version}: KMS rejected the manifest signature`);
242
+
243
+ const readArtifact = options.readArtifact;
244
+ if (readArtifact === undefined) return parsed;
245
+ for (const [id, artifact] of Object.entries(parsed.lambda)) {
246
+ const bytes = await readArtifact(artifact.key);
247
+ if (bytes.byteLength !== artifact.bytes)
248
+ throw new Error(
249
+ `release ${parsed.version}: ${id} is ${bytes.byteLength} bytes, manifest says ${artifact.bytes}`,
250
+ );
251
+ assertDigest(`${parsed.version}: ${id}`, bytes, artifact.sha256);
252
+ }
253
+ assertDigest(
254
+ `${parsed.version}: skills`,
255
+ await readArtifact(parsed.skills.key),
256
+ parsed.skills.sha256,
257
+ );
258
+ return parsed;
259
+ }
260
+
261
+ function assertDigest(what: string, bytes: Uint8Array, expected: string): void {
262
+ const actual = sha256Hex(bytes);
263
+ if (actual !== expected)
264
+ throw new Error(`release ${what}: sha256 ${actual}, manifest says ${expected}`);
265
+ }