An authenticated, multi-product software update channel on AWS — the backend that decides who is allowed to download which build, and hands out short-lived, presigned download URLs instead of making artifacts public.
It is built from four small, dependency-free pieces:
- Feed — an AWS Lambda (behind API Gateway) that authenticates a bearer token, checks that the token is entitled to the requested product, and returns a manifest or a presigned S3 URL.
- CDK — one reusable stack construct, instantiated twice to produce two IAM-isolated copies of the channel that share zero blast radius.
- Publisher — a CLI that builds a content-hashed manifest and uploads the artifact + manifest to the right product/channel prefix in S3.
- Client — a tiny Node feed client that checks for an update and resolves the download URL.
Extracted and generalized from a production update system I built and operated (it shipped several desktop and CLI products across many release channels). Product names and internals have been replaced with generic examples; the architecture and tests are the real thing.
Shipping auto-updates to installed desktop/CLI software raises three problems that a public S3 bucket doesn't solve:
- Entitlement — not every client should be able to pull every product. A token must reach only the products it is granted, so one product's credentials can't fetch another's builds.
- Private artifacts — builds shouldn't be world-readable. The feed keeps the bucket private and issues presigned URLs with a short TTL per request.
- Blast-radius isolation — a shared-workspace collaborator who can publish product A must have no path — via IAM or via a shared Lambda — to product B. That is enforced by running two physically separate stacks, not by application logic alone.
┌──────────────────────── AWS ────────────────────────┐
client ──bearer token──► API Gateway ──► Lambda (feed) │
│ │ │
│ auth: token ─► entitled products? (constant-time) │
│ │ │
│ route: /v1/{product}/manifest/{channel}.json │
│ /v1/{product}/config.json │
│ │ │
│ ┌── manifest ─────┴── presigned GET ──► S3 (private) ─┤
◄── 200 manifest / 302 presigned URL ──────────────────────────────────────── ┘
publisher ──build manifest (sha256, size, version)──► S3 put (artifact + manifest)
│
Stack B publishes only if the caller's AWS identity
is the designated deployer (STS identity guard)
- Two stacks, one construct.
Stack Aserves the everyday products a collaborator can publish to.Stack Bis isolated: the publisher refuses to upload to it unless the caller's resolved AWS identity is the designated deployer, so an accidentalStack A-shaped publish can never land a build in the isolated bucket. - Tokens map to products. The feed reads an
ACCESS_TOKENSmap of{ "token": ["product", …] }. Token comparison istimingSafeEqualover SHA-256 digests, so it leaks nothing through timing — including length. - Channels are a product's parallel release streams (e.g.
payload,app,config,beta,bin,full). Large channels get a longer presigned-URL TTL so slow downloads don't expire mid-transfer. - Manifests are content-addressed:
{ version, key, sha256, size, notes, minHostVersion }. The client verifies the hash after download. - Legacy flat routes (
/manifest.json,/config, …) are kept as permanent aliases so already-installed clients that hard-coded them never lose their update path.
src/feed/ the Lambda feed
auth.mjs token → entitled-products map; constant-time check
routes.mjs pure URL parsing (no IO), exhaustively unit-tested
handler.mjs manifest lookup + presigned-URL issuance
index.mjs Lambda entrypoint
src/publish/
ship.mjs publisher: build manifest, validate, upload; Stack B identity guard
src/client/node/
feedclient.js update check + download-URL resolution (CommonJS-consumable)
cdk/
update-feed-stack.ts the reusable stack (S3 + Lambda + API GW + least-privilege IAM)
bin/app.ts instantiates it twice → two IAM-isolated stacks
tests/ node:test suites for feed, auth, routes, publisher, client
- Zero runtime dependencies. The feed, publisher, and client run on the Node standard library
only (
node:crypto,node:test,node:assert). The only third-party dependencies are the CDK libraries, and they are isolated incdk/so nothing else inherits them. - Pure route parsing. URL parsing is a separate, IO-free module — the one piece of logic a deployed client's URL actually hits — so it can be tested exhaustively without mocking AWS.
- Frozen wire shapes. Response shapes that installed clients already parse are treated as permanent contracts; the tests encode that so a refactor can't silently strand old installs.
# Feed, auth, routes, publisher, client — no network, no AWS, no filesystem outside a temp dir
node --test tests/*.mjs # 66 tests
# CDK stack assertions (installs aws-cdk-lib locally, then runs synth-based tests)
cd cdk && npm install && npm testcd cdk
npm install
# Configure the two stacks (bucket/function/role names, product lists, ACCESS_TOKENS) in bin/app.ts
npx cdk deploy --allPublish a build:
node src/publish/ship.mjs desktop-app payload ./build/desktop-app-1.4.0.zip
# builds a sha256 manifest and uploads artifact + manifest under desktop-app/ in the Stack A bucketMIT — see LICENSE.