Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ on:
workflow_call:

jobs:
integration:
name: Integration
node:
name: Node ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['22', '24']
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
node-version: ${{ matrix.node }}

- name: Install dependencies
run: npm ci
Expand All @@ -29,3 +33,72 @@ jobs:

- name: Test
run: npm test

- name: Smoke (dry run on Node)
run: node scripts/smoke.mjs

bun:
name: Bun ${{ matrix.bun }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
bun: ['1.3.14']
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js (for npm + generators)
uses: actions/setup-node@v6
with:
node-version: '22'

- name: Setup Bun
uses: oven-sh/setup-bun@v2
Comment thread
rubenhensen marked this conversation as resolved.
with:
bun-version: ${{ matrix.bun }}

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

Comment thread
rubenhensen marked this conversation as resolved.
- name: Test (vitest on Bun runtime)
run: bunx vitest run

- name: Smoke (dry run on Bun)
run: bun scripts/smoke.mjs

deno:
name: Deno ${{ matrix.deno }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
deno: ['2.8.0']
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js (for npm + generators)
uses: actions/setup-node@v6
with:
node-version: '22'

- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: ${{ matrix.deno }}

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Test (vitest on Deno runtime)
run: deno run --allow-all npm:vitest run

- name: Smoke (dry run on Deno)
run: deno run -A scripts/smoke.mjs
84 changes: 84 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project overview

`@e4a/pg-js` — TypeScript browser+Node SDK for PostGuard. PostGuard performs identity-based encryption (IBE): senders encrypt for *identity attributes* (email, phone, etc.) and recipients prove that identity via [Yivi](https://yivi.app) to decrypt. This SDK wraps the `@e4a/pg-wasm` cryptographic core, the PKG (key-generation service) HTTP API, the Cryptify upload server HTTP API, and the Yivi client widgets, exposing them through a small lazy builder surface.

## Common commands

| Task | Command |
|------------------------------|----------------------|
| Install dependencies | `npm install` |
| Build (ESM + `.d.mts`) | `npm run build` |
| Watch-mode build | `npm run dev` |
| Type-check (no emit) | `npm run typecheck` |
| Run all tests once | `npm test` |
| Watch tests | `npm run test:watch` |
| Run a single test file | `npx vitest run tests/api.test.ts` |
| Run a single test by name | `npx vitest run -t "name fragment"` |

### Prebuild generators (important)

`prebuild`, `pretypecheck`, and `pretest` all run two generator scripts:

- `scripts/generate-wasm-base64.mjs` — reads `node_modules/@e4a/pg-wasm/web/index_bg.wasm`, writes `src/util/wasm-binary.ts` (base64 of the WASM) AND `src/util/pg-wasm-shim.js` (a patched copy of pg-wasm's `index.js` with wasm-bindgen's `new URL("index_bg.wasm", import.meta.url)` default-value branch stripped — that branch never fires at runtime but webpack 5 fails on it because no separate WASM file ships in our dist).
- `scripts/generate-yivi-css.mjs` — reads `node_modules/@privacybydesign/yivi-css/dist/yivi.css` and writes `src/yivi/yivi-css-text.ts` as a string constant.

All three generated files are git-ignored. If `npm run dev` (which does not run prebuild) is used on a fresh clone, the build will fail until the generators run. Run `npm run prebuild` once, or use `npm run build` / `npm test`.

If `generate-wasm-base64.mjs` errors that the regex no longer matches, wasm-bindgen has changed its output shape — update the regex (or drop the patch entirely if upstream is clean now).

## Architecture

### Lazy builder surface

The public API is intentionally tiny. `PostGuard` (`src/postguard.ts`, extending `PostGuardBase` in `src/postguard-base.ts`) exposes:

- `pg.encrypt(input)` → returns a lazy `Sealed` (`src/sealed.ts`). Nothing executes until `.toBytes()` or `.upload()` is called.
- `pg.open(input)` → returns a lazy `Opened` (`src/opened.ts`). Inspect-before-decrypt pattern; `.inspect()` reads the header without unsealing, and `.decrypt()` reuses the cached unsealer.
- `pg.sign.{apiKey,yivi,session}(...)` and `pg.recipient.{email,emailDomain}(...)` — small factory helpers exposed as readonly fields. The `recipient.*` factories return `RecipientBuilder` (`src/recipients/builder.ts`), which is the fluent shape consumers use to attach extra attribute constraints.
- `pg.email` — `EmailHelpers` (`src/email/index.ts`) for MIME-envelope construction, sized into three tiers (URL fragment / inline attachment / Cryptify upload). See `EnvelopeTier` and `createEnvelope` if you touch the email-addon path.

Two builder modes exist for `encrypt`: `files` (zipped first, then sealed) and `data` (raw bytes/stream, sealed directly — used for MIME envelopes). `Sealed.mode` reports which mode was selected so downstream code (e.g. `createEnvelope`) can choose the right decrypt URL.

### Core modules

- `src/crypto/` — `encrypt.ts` (full encrypt + upload pipeline), `decrypt.ts` (inspect/unseal), `chunker.ts` (streaming chunk transform), `signing.ts` (resolves a `SigningKeys` from any `SignMethod`).
- `src/api/` — `pkg.ts` (PostGuard key-generation server: MPK, USKs, signing sessions) and `cryptify.ts` (chunked upload + download).
- `src/signing/` — strategies the `SigningKeys` resolver dispatches to: `api-key.ts`, `yivi.ts`, `session.ts`.
- `src/util/` — `wasm.ts` (single-shot pg-wasm initializer using the base64-embedded binary), `zip.ts` (Conflux-based streaming ZIP), `retry.ts` (exponential backoff + jitter for Cryptify chunk PUT/GET; see `RetryOptions`), `identity.ts` (extract `FriendlySender` from sealed sender attributes).
- `src/yivi/` — `inject-css.ts` (Shadow-DOM-safe injection of the embedded Yivi CSS), `decrypt-session.ts` (USK retrieval via QR), `yivi-css-text.ts` (generated).

### pg-wasm integration

Treat `loadWasm()` (`src/util/wasm.ts`) as the only entry to the WASM module. It caches after first call. Never import `@e4a/pg-wasm` directly — the generated shim is what we actually bundle, and bypassing it will reintroduce the webpack `new URL(...)` failure.

### Bundling

`tsdown.config.ts`: ESM-only output, type declarations on, splitting + treeshake on, `@transcend-io/conflux` is `neverBundle`'d (the consumer resolves it, keeping the dist tree-shakeable). `target: false` — we ship modern ES; consumers do their own downleveling.

The package is `"type": "module"` and `"sideEffects": false`. Always use `.js` extensions on relative imports in source (TS resolves them as `.ts` but the emitted ESM needs them).

## Tests

Vitest with Node default environment. Browser-only paths (Yivi QR widgets, `triggerBrowserDownload`) are not covered by the unit tests — those need a real browser and live PKG/Cryptify endpoints. `tests/api.test.ts` is the broad integration of the encrypt/upload/open/decrypt flow against mocked PKG/Cryptify; the smaller files (`chunker`, `zip`, `errors`, `decrypt-session`, `recipients`, `exports`, `postguard`) target single units.

## Supported runtimes

- **Browser** — full surface, including Yivi.
- **Node 22+ / Bun / Deno** — encrypt + upload + decrypt paths work for `sign.apiKey` and `sign.session`. `sign.yivi(...)` throws a clear `YiviSessionError` upfront (it needs a DOM). `result.download()` is browser-only; `result.blob` / `result.plaintext` are universal. Node 22 is the floor because tsdown (the build tool) requires 22.18+; the SDK runtime itself would otherwise work on Node 20.3+, but we don't test or claim support there.

Two non-obvious gotchas for non-browser callers, both already handled in the SDK:

- `FileList` is browser-only. `src/sealed.ts` typeof-guards the `instanceof FileList` check so Node doesn't throw `ReferenceError`.
- `@transcend-io/conflux/dist/esm/bigint.js` references the browser-only `self` global at module load. Bun and Deno alias `self === globalThis`; Node does not. `src/util/zip.ts:importConfluxWithSelfShim()` sets `globalThis.self = globalThis` only for the duration of the dynamic import and restores the prior state in a `finally` — no permanent global mutation.

There's a manual smoke test at `scripts/smoke.mjs` runnable under any of the four runtimes. Without `PG_API_KEY` it runs static checks; with one it does a real upload to staging Cryptify.

## Releases and CI

- `main` is the release branch. `npx semantic-release` runs on push to `main` (`.github/workflows/delivery.yml`), so **commit messages and PR titles must follow Conventional Commits** — `.github/workflows/pr-title.yml` enforces this via `action-semantic-pull-request`.
- `.github/workflows/integration.yml` runs `typecheck + build + test + smoke` across Node 22/24, Bun 1.3.14, and Deno 2.8.0 on every PR. Get the Node lanes green locally before pushing.
- Version in `package.json` is a placeholder (`0.0.0-managed-by-semantic-release`) — do not edit it manually; semantic-release rewrites it during publish.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,39 @@ result.download();

See the [full API reference](https://docs.postguard.eu/repos/postguard-js) for encryption options, signing methods, recipient types, and email helpers.

## Server-side usage (Node, Bun, Deno)

The SDK works in non-browser runtimes for the encrypt + upload path
when signing via `sign.apiKey` or a custom `sign.session` callback. No
polyfills required.

**Minimum runtime versions**:

| Runtime | Minimum | Notes |
| ------- | ------- | ------------------------------------------------------------------------------------------- |
| Node | 22+ | Enforced via `engines.node`. The build tool requires Node 22.18+; runtime is tested on 22 and 24. |
| Bun | 1.0.16+ | First release with `AbortSignal.any` (the SDK's tightest web-API requirement) |
| Deno | 1.39+ | First release with `AbortSignal.any` |

Other notes:

- `sign.yivi(...)` requires a DOM and is browser-only. The SDK throws a
clear `YiviSessionError` upfront on non-browser runtimes — use
`sign.apiKey` or `sign.session` instead.
- For decryption, `result.blob` and `result.plaintext` are universal;
`result.download(...)` triggers a browser download and is browser-only.
- `sealed.upload()` refuses `data: ReadableStream` — use
`sealed.toBytes()` for streaming, or pass `data` as `Uint8Array`.

A manual smoke test for any runtime lives at `scripts/smoke.mjs`. Set
`PG_API_KEY` to a staging key to exercise the full upload pipeline:

```bash
PG_API_KEY=PG-... node scripts/smoke.mjs
PG_API_KEY=PG-... bun scripts/smoke.mjs
PG_API_KEY=PG-... deno run -A scripts/smoke.mjs
```

## Development

Install dependencies and build:
Expand Down
Loading
Loading