Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# @suaveplan/drive-template - Server-signed drive templates: pre-configured folder structure and per-file metadata with bytes hydrated lazily from peers and CDN. Per-file policy flags (autoDownload/meshAllowed/cdnAllowed/retention), CDN reversed-ID URL builder, key grant envelopes, replay-protected signature verification.
## Summary
Server-signed drive templates for the dehydrated-drive workflow. A `DriveTemplate` describes a drive's full folder structure and per-file metadata; the actual bytes are fetched lazily from peers (via `drive-mesh`) or a CDN (via `drive-transport-https`) on first read or on hydrate-time pre-fetch. This package ships the wire types, error registry, and the CDN reversed-ID URL builder. The hydration runtime, signature verification, and key-grant validation will land in subsequent commits.
<!-- begin:auto-docs -->
## Documentation tree
- [upload-token](./src/cdn/upload-token.md) — Implements `CdnUploadToken` issuance and verification (DTP-19). Tokens authorise a single chunk write to the CDN and ca…
- [url-builder](./src/cdn/url-builder.md) — Deterministic URL builder for the reversed-ID CDN layout described in `drive-template-v1` `DTP-17`. Given a `CdnFallbac…
- [canonical](./src/codec/canonical.md) — Implements `canonicalSerialize`, the deterministic byte-level encoding that gates every server signature on a `DriveTem…
- [sign](./src/codec/sign.md) — Server-side signing helper that turns an unsigned `DriveTemplate` into a fully-formed envelope (DTP-04). Produces an Ed…
- [verify](./src/codec/verify.md) — Server-signature verification for `DriveTemplate` (DTP-04). Validates that `template.serverSignature` is a correct Ed25…
- [key](./src/key/grant-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [grant-validator](./src/key/grant-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [template.schema](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [depth](./src/validate/depth.md) — Folder-depth check for `DriveTemplate.rootFolder` (DTP-08). Counts from the root (depth 0) and rejects the first folder…
- [file](./src/validate/file.md) — Per-file consistency invariants for every `FileTemplate` (DTP-09). Files that fail any invariant are rejected with `DRI…
- [order](./src/validate/order.md) — Implements the 8-step validation pipeline that `Drive.hydrateFrom(template)` runs against every incoming `DriveTemplate…
- [replay](./src/validate/replay.md) — Per-client LRU of `(driveId, nonce)` tuples used to reject replayed templates (DTP-06). The cache holds at most `LRU_CA…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-template` package. Eleven codes cover the…
<!-- end:auto-docs --> - [grant-validator](./src/key/grant-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [template.schema](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [depth](./src/validate/depth.md) — Folder-depth check for `DriveTemplate.rootFolder` (DTP-08). Counts from the root (depth 0) and rejects the first folder…
- [file](./src/validate/file.md) — Per-file consistency invariants for every `FileTemplate` (DTP-09). Files that fail any invariant are rejected with `DRI…
- [order](./src/validate/order.md) — Implements the 8-step validation pipeline that `Drive.hydrateFrom(template)` runs against every incoming `DriveTemplate…
- [replay](./src/validate/replay.md) — Per-client LRU of `(driveId, nonce)` tuples used to reject replayed templates (DTP-06). The cache holds at most `LRU_CA…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-template` package. Eleven codes cover the…
- [grant-validator](./src/key/grant-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [template.schema](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [depth](./src/validate/depth.md) — Folder-depth check for `DriveTemplate.rootFolder` (DTP-08). Counts from the root (depth 0) and rejects the first folder…
- [file](./src/validate/file.md) — Per-file consistency invariants for every `FileTemplate` (DTP-09). Files that fail any invariant are rejected with `DRI…
- [order](./src/validate/order.md) — Implements the 8-step validation pipeline that `Drive.hydrateFrom(template)` runs against every incoming `DriveTemplate…
- [replay](./src/validate/replay.md) — Per-client LRU of `(driveId, nonce)` tuples used to reject replayed templates (DTP-06). The cache holds at most `LRU_CA…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-template` package. Eleven codes cover the…
- [grant-validator](./src/key/grant-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [template.schema](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [depth](./src/validate/depth.md) — Folder-depth check for `DriveTemplate.rootFolder` (DTP-08). Counts from the root (depth 0) and rejects the first folder…
- [file](./src/validate/file.md) — Per-file consistency invariants for every `FileTemplate` (DTP-09). Files that fail any invariant are rejected with `DRI…
- [order](./src/validate/order.md) — Implements the 8-step validation pipeline that `Drive.hydrateFrom(template)` runs against every incoming `DriveTemplate…
- [replay](./src/validate/replay.md) — Per-client LRU of `(driveId, nonce)` tuples used to reject replayed templates (DTP-06). The cache holds at most `LRU_CA…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-template` package. Eleven codes cover the…
- [grant-validator](./src/key/grant-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [template.schema](./src/schemas/template.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [depth](./src/validate/depth.md) — Folder-depth check for `DriveTemplate.rootFolder` (DTP-08). Counts from the root (depth 0) and rejects the first folder…
- [file](./src/validate/file.md) — Per-file consistency invariants for every `FileTemplate` (DTP-09). Files that fail any invariant are rejected with `DRI…
- [order](./src/validate/order.md) — Implements the 8-step validation pipeline that `Drive.hydrateFrom(template)` runs against every incoming `DriveTemplate…
- [replay](./src/validate/replay.md) — Per-client LRU of `(driveId, nonce)` tuples used to reject replayed templates (DTP-06). The cache holds at most `LRU_CA…
- [error-codes](./src/error-codes.md) — Frozen registry of error codes plus the error class for the `@suaveplan/drive-template` package. Eleven codes cover the…
## Install
```bash
bun add @suaveplan/drive-template
```
## Quick start
```ts
import {
buildCdnChunkUrl,
DEFAULT_CDN_LEVELS,
DEFAULT_CDN_PADDING,
DTP_CODES,
DriveTemplateError,
type DriveTemplate,
} from "@suaveplan/drive-template";
// Build a CDN chunk URL using the reversed-ID layout.
const url = buildCdnChunkUrl(
{
baseUrl: "https://chunks.example.com",
pathScheme: "reverse-id",
levels: DEFAULT_CDN_LEVELS,
padding: DEFAULT_CDN_PADDING,
},
"abcdef1234567890",
47,
);
// → "https://chunks.example.com/0/9/8/abcdef1234567890_000000047.chunk"
// Errors carry stable codes from DTP_CODES.
try {
buildCdnChunkUrl({ baseUrl: "x", pathScheme: "reverse-id", levels: 3, padding: 9 }, "", 0);
} catch (err) {
if (err instanceof DriveTemplateError && err.code === DTP_CODES.TEMPLATE_MALFORMED) {
console.error("invalid input");
}
}
```
## Modules
- [`src/error-codes.md`](./src/error-codes.md) — `DTP_CODES` registry (eleven codes covering the eight-step validation pipeline plus three operational paths) and the `DriveTemplateError` class.
- [`src/cdn/url-builder.md`](./src/cdn/url-builder.md) — `buildCdnChunkUrl` plus `DEFAULT_CDN_LEVELS` and `DEFAULT_CDN_PADDING` constants.
- `src/types/template.types.ts` — `DriveTemplate`, `FolderTemplate`, `FileTemplate`, `PerFilePolicy`, `RetentionPolicy`, `FileEncryption`, `KeyDistribution`, `CdnFallbackConfig`, `CdnUploadToken`, `KeyGrantPayload`.
## Subpath exports
- `@suaveplan/drive-template` — full barrel.
- `@suaveplan/drive-template/types` — wire types only.
- `@suaveplan/drive-template/cdn` — CDN URL builder.
- `@suaveplan/drive-template/errors` — `DTP_CODES`, `DriveTemplateError`.
## Spec reference
The wire format and validation pipeline are canonical in the specification (DTP-01..DTP-26). The architectural reference is [`docs/drive/dehydrated-drives.md`](../../../docs/drive/dehydrated-drives.md).
## Status
Skeleton — types, error registry, and CDN URL builder shipped. Canonical serialization, signature verification, replay LRU, hydration orchestrator, and `KeyGrantEnvelope` validator land in subsequent commits.

View File

@@ -0,0 +1,109 @@
{
"name": "@suaveplan/drive-template",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-signed drive templates: pre-configured folder structure and per-file metadata with bytes hydrated lazily from peers and CDN. Per-file policy flags (autoDownload/meshAllowed/cdnAllowed/retention), CDN reversed-ID URL builder, key grant envelopes, replay-protected signature verification.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./cdn": {
"import": {
"types": "./dist/cdn/index.d.ts",
"default": "./dist/cdn/index.js"
}
},
"./codec": {
"import": {
"types": "./dist/codec/index.d.ts",
"default": "./dist/codec/index.js"
}
},
"./validate": {
"import": {
"types": "./dist/validate/index.d.ts",
"default": "./dist/validate/index.js"
}
},
"./key": {
"import": {
"types": "./dist/key/index.d.ts",
"default": "./dist/key/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/error-codes.d.ts",
"default": "./dist/error-codes.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@noble/curves": "catalog:crypto",
"@noble/hashes": "catalog:crypto",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,12 @@
export {
canonicalSerializeUploadToken,
signUploadToken,
UPLOAD_TOKEN_TTL_DEFAULT_MS,
UPLOAD_TOKEN_TTL_MAX_MS,
verifyUploadToken,
} from "./upload-token.js";
export {
buildCdnChunkUrl,
DEFAULT_CDN_LEVELS,
DEFAULT_CDN_PADDING,
} from "./url-builder.js";

View File

@@ -0,0 +1,70 @@
# upload-token
## Purpose
Implements `CdnUploadToken` issuance and verification (DTP-19). Tokens authorise a single chunk write to the CDN and carry a server signature over the canonical bytes plus a content-hash binding so the CDN can refuse to store anything other than what the server attested.
## Features
- `signUploadToken(token, serverSecretKey, options?)` — fills `expiresAt` from the injected `Clock` + `ttlMs` and signs.
- `verifyUploadToken(token, serverPubkeyHex, clock)` — full check: expiry, hex decode, Ed25519 verify.
- `canonicalSerializeUploadToken(token)` — deterministic byte-level encoding shared by issuer and verifier.
- `UPLOAD_TOKEN_TTL_DEFAULT_MS` (60 s), `UPLOAD_TOKEN_TTL_MAX_MS` (300 s) — pinned constants the spec dashboards can reference.
## Usage: Basic Example
```ts
import {
signUploadToken,
verifyUploadToken,
} from "@suaveplan/drive-template";
const tok = signUploadToken(
{
fileId,
chunkId: 0,
chunkHash: "blake3:abc",
uploaderPubkey: peerId,
} as never,
serverSecretKey,
{ clock: () => Date.now() }
);
verifyUploadToken(tok, serverPubkeyHex, () => Date.now());
```
## Usage: Advanced Example
```ts
import {
DTP_CODES,
DriveTemplateError,
signUploadToken,
verifyUploadToken,
} from "@suaveplan/drive-template";
async function handleCdnUpload(token, body) {
try {
verifyUploadToken(token, serverPubkeyHex, () => Date.now());
} catch (err) {
if (err instanceof DriveTemplateError && err.code === DTP_CODES.CDN_BAD_SIGNATURE) {
return new Response("forbidden", { status: 403 });
}
throw err;
}
if (`blake3:${blake3Hex(body)}` !== token.chunkHash) {
return new Response("hash mismatch", { status: 422 });
}
return store(token.fileId, token.chunkId, body);
}
```
## API Reference
- `signUploadToken(token, serverSecretKey, options?: { clock: Clock; ttlMs?: number }): CdnUploadToken`.
- `verifyUploadToken(token: CdnUploadToken, serverPubkeyHex: string, clock: Clock): true`.
- `canonicalSerializeUploadToken(token: Omit<CdnUploadToken, "serverSignature">): Uint8Array`.
- Constants: `UPLOAD_TOKEN_TTL_DEFAULT_MS`, `UPLOAD_TOKEN_TTL_MAX_MS`.
## Implementation Notes
The TTL upper bound is enforced at sign time to prevent a misconfigured caller from minting tokens that outlive the request that asked for them. Verification trusts `@noble/curves` to return `false` for malformed signatures — there's no separate try/catch, which removes a dead branch and keeps the success path branch-free in the hot path. The CDN-side replay protection (the `(fileId, chunkId, nonce)` short LRU mentioned in DTP-19) lives in the CDN service rather than this module; the protocol only mandates the token shape.

View File

@@ -0,0 +1,144 @@
/**
* `CdnUploadToken` issuance and verification (DTP-19).
*
* Tokens authorise a single chunk write to the CDN. The CDN enforces:
*
* 1. Server signature over `canonicalSerializeUploadToken(token - sig)`.
* 2. `BLAKE3(body) === chunkHash`.
* 3. `now < expiresAt` (default 60 s window).
* 4. `uploaderPubkey` matches the bearer's identity.
* 5. `(fileId, chunkId, nonce)` not in the CDN's short LRU.
*/
import { sign, verify } from "@suaveplan/crypto-curve/ed25519";
import { fromHex } from "@suaveplan/crypto-utils";
import type { Clock } from "@suaveplan/types";
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { CdnUploadToken } from "../types/template.types.js";
/** Default 60-second token expiry. */
export const UPLOAD_TOKEN_TTL_DEFAULT_MS = 60_000 as const;
/** Max accepted token expiry — rejects pathologically long-lived tokens. */
export const UPLOAD_TOKEN_TTL_MAX_MS = 300_000 as const;
/** Canonical bytes covered by `serverSignature`. */
export function canonicalSerializeUploadToken(
token: Omit<CdnUploadToken, "serverSignature">
): Uint8Array {
const enc = new TextEncoder();
const fileId = enc.encode(token.fileId);
const chunkHash = enc.encode(token.chunkHash);
const uploader = enc.encode(token.uploaderPubkey);
const out = new Uint8Array(
4 + fileId.length + 4 + 4 + chunkHash.length + 4 + uploader.length + 8
);
let off = 0;
off = writeU32(out, off, fileId.length);
out.set(fileId, off);
off += fileId.length;
off = writeU32(out, off, token.chunkId);
off = writeU32(out, off, chunkHash.length);
out.set(chunkHash, off);
off += chunkHash.length;
off = writeU32(out, off, uploader.length);
out.set(uploader, off);
off += uploader.length;
new DataView(out.buffer, out.byteOffset, out.byteLength).setBigUint64(
off,
BigInt(token.expiresAt),
false
);
return out;
}
function writeU32(buf: Uint8Array, off: number, value: number): number {
new DataView(buf.buffer, buf.byteOffset, buf.byteLength).setUint32(
off,
value >>> 0,
false
);
return off + 4;
}
/** Sign a fresh `CdnUploadToken` with the server's Ed25519 secret key. */
export function signUploadToken(
token: Omit<CdnUploadToken, "serverSignature">,
serverSecretKey: Uint8Array,
options: { readonly clock: Clock; readonly ttlMs?: number } = {
clock: () => Date.now(),
}
): CdnUploadToken {
const ttl = options.ttlMs ?? UPLOAD_TOKEN_TTL_DEFAULT_MS;
if (ttl <= 0 || ttl > UPLOAD_TOKEN_TTL_MAX_MS) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
`ttlMs must be in (0, ${UPLOAD_TOKEN_TTL_MAX_MS}]; got ${ttl}`
);
}
const expiresAt = options.clock() + ttl;
const filled: Omit<CdnUploadToken, "serverSignature"> = {
...token,
expiresAt,
};
const canonical = canonicalSerializeUploadToken(filled);
const serverSignature = sign(canonical, serverSecretKey);
return { ...filled, serverSignature };
}
/**
* Verify a `CdnUploadToken`. Throws `DriveTemplateError` with the
* appropriate failure code; returns `true` on success.
*/
/** Verifier injection point so tests can exercise the catch arm. */
export type VerifyEd25519Fn = (
sig: Uint8Array,
msg: Uint8Array,
pub: Uint8Array
) => boolean;
export function verifyUploadToken(
token: CdnUploadToken,
serverPubkeyHex: string,
clock: Clock,
verifyFn: VerifyEd25519Fn = verify
): true {
if (clock() >= token.expiresAt) {
throw new DriveTemplateError(
DTP_CODES.CDN_BAD_SIGNATURE,
`upload token expired at ${token.expiresAt}`
);
}
let pub: Uint8Array;
try {
pub = fromHex(serverPubkeyHex);
} catch (cause) {
throw new DriveTemplateError(
DTP_CODES.CDN_BAD_SIGNATURE,
`server pubkey is not valid hex: ${String(cause)}`,
{ cause: cause as Error }
);
}
const { serverSignature, ...rest } = token;
const canonical = canonicalSerializeUploadToken(rest);
// Forward-defensive catch — see codec/verify.ts. `verifyFn` is
// injectable so tests can pass a throwing implementation; defaults
// to noble's verify in production.
let ok = false;
try {
ok = verifyFn(serverSignature, canonical, pub);
} catch (cause) {
throw new DriveTemplateError(
DTP_CODES.CDN_BAD_SIGNATURE,
`Ed25519 verification raised: ${String(cause)}`,
{ cause: cause as Error }
);
}
if (!ok) {
throw new DriveTemplateError(
DTP_CODES.CDN_BAD_SIGNATURE,
"upload token serverSignature does not verify"
);
}
return true;
}

View File

@@ -0,0 +1,94 @@
# buildCdnChunkUrl
## Purpose
Deterministic URL builder for the reversed-ID CDN layout described in `drive-template-v1` `DTP-17`. Given a `CdnFallbackConfig` and a `(fileId, chunkId)` pair, produces the canonical chunk URL: `<baseUrl>/<reversed[0]>/<reversed[1]>/.../<reversed[levels-1]>/<fileId>_<chunkIdPadded>.chunk`. Reversing the file id distributes leaves randomly across the directory tree regardless of how IDs were generated (sequential timestamps, hash prefixes, etc.) — three default directory levels with a 16-character hex alphabet give 4,096 leaf directories, enough to keep any single directory under 250 entries at 1 M files.
## Features
- Deterministic — identical inputs produce byte-identical URLs.
- Parameterised by `levels` (directory depth) and `padding` (chunk-id zero-pad width) so deployments can tune for their file count and chunk count.
- Handles trailing-slash normalisation on `baseUrl`.
- Validates inputs aggressively: empty file id, negative or non-integer `levels` / `padding` / `chunkId`, `levels > fileId.length` all throw `DriveTemplateError(DTP.TEMPLATE_MALFORMED)`.
- Pure — no I/O, no side effects, no closures over external state.
## Usage: Basic Example
```ts
import { buildCdnChunkUrl } from "@suaveplan/drive-template/cdn";
const url = buildCdnChunkUrl(
{
baseUrl: "https://chunks.example.com",
pathScheme: "reverse-id",
levels: 3,
padding: 9,
},
"abcdef1234567890",
47,
);
// → "https://chunks.example.com/0/9/8/abcdef1234567890_000000047.chunk"
```
## Usage: Advanced Example
```ts
import {
buildCdnChunkUrl,
DEFAULT_CDN_LEVELS,
DEFAULT_CDN_PADDING,
} from "@suaveplan/drive-template/cdn";
// Build URLs for every chunk in a small file.
const cfg = {
baseUrl: "https://cdn.example.com/", // trailing slash is normalised
pathScheme: "reverse-id" as const,
levels: DEFAULT_CDN_LEVELS,
padding: DEFAULT_CDN_PADDING,
};
const fileId = "f-1729a4f72c3b";
const totalChunks = 5;
const urls = Array.from({ length: totalChunks }, (_, chunkId) =>
buildCdnChunkUrl(cfg, fileId, chunkId),
);
console.log(urls);
// [
// "https://cdn.example.com/b/3/c/f-1729a4f72c3b_000000000.chunk",
// "https://cdn.example.com/b/3/c/f-1729a4f72c3b_000000001.chunk",
// "https://cdn.example.com/b/3/c/f-1729a4f72c3b_000000002.chunk",
// "https://cdn.example.com/b/3/c/f-1729a4f72c3b_000000003.chunk",
// "https://cdn.example.com/b/3/c/f-1729a4f72c3b_000000004.chunk",
// ]
```
## API Reference
### `const DEFAULT_CDN_LEVELS`
The literal `3`. Default directory depth for `reverse-id` URLs. With a 16-character hex alphabet this gives 4,096 leaf directories.
### `const DEFAULT_CDN_PADDING`
The literal `9`. Default chunk-id zero-pad width. Supports up to 999,999,999 chunks per file (~64 PB at 64 KiB chunks).
### `function buildCdnChunkUrl(cfg, fileId, chunkId): string`
- `cfg: CdnFallbackConfig` — the CDN configuration. Trailing slashes on `baseUrl` are normalised away.
- `fileId: string` — the 32-byte hex file identifier. Must be non-empty.
- `chunkId: number` — non-negative integer chunk id.
- Returns the canonical URL.
- Throws `DriveTemplateError(DTP.TEMPLATE_MALFORMED)` for invalid inputs.
## Implementation Notes
The reversal logic is implemented as a small loop rather than `String.prototype.split('').reverse().join('')` because the latter creates an intermediate array; for hot paths (a CDN fetch loop running over thousands of chunks) the loop is measurably faster.
The `levels` parameter is allowed to be `0`, which produces URLs with no directory levels — `<baseUrl>/<fileId>_<chunkIdPadded>.chunk`. This is occasionally useful for small CDNs where the directory split adds operational complexity without solving an actual hot-directory problem.
The `padding` parameter MAY be `0`, which omits zero-padding entirely. This is wire-compatible with CDNs that already serve un-padded chunk ids; the suaveplan convention is `9` for new deployments.
The function is intentionally pure and synchronous. All async behavior — fetching, retry, signature verification — lives in `drive-transport-https`. Keeping the URL builder pure makes it testable in isolation and reusable from any context (browser, Node, edge worker).
When `cfg.pathScheme` is set to a value other than `"reverse-id"`, the builder currently has no other implementation; future schemes (forward-id, deep-hash) would dispatch on the discriminator inside this module.

View File

@@ -0,0 +1,81 @@
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { CdnFallbackConfig } from "../types/template.types.js";
/** Default directory depth for the reversed-ID layout. */
export const DEFAULT_CDN_LEVELS = 3 as const;
/** Default zero-pad width for chunk-id segments. */
export const DEFAULT_CDN_PADDING = 9 as const;
/**
* Build the CDN URL for a single chunk using the reversed-ID layout.
*
* URL shape: `<baseUrl>/<reversed[0]>/<reversed[1]>/.../<reversed[levels-1]>/<fileId>_<chunkIdPadded>.chunk`
*
* Where `reversed = fileId.split('').reverse().join('')` and
* `chunkIdPadded` is `chunkId.toString().padStart(padding, '0')`.
*
* @example
* buildCdnChunkUrl({ baseUrl: 'https://chunks.example.com', pathScheme: 'reverse-id', levels: 3, padding: 9 }, 'abc123', 47)
* // → 'https://chunks.example.com/3/2/1/abc123_000000047.chunk'
*
* @throws {@link DriveTemplateError} with code `DTP.TEMPLATE_MALFORMED` when
* `levels` exceeds the file id length, `padding` is negative, `chunkId`
* is negative or non-integer, or `fileId` is empty.
*/
export function buildCdnChunkUrl(
cfg: CdnFallbackConfig,
fileId: string,
chunkId: number
): string {
if (fileId.length === 0) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
"fileId must be non-empty"
);
}
if (cfg.levels < 0 || !Number.isInteger(cfg.levels)) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
`cfg.levels must be a non-negative integer; got ${cfg.levels}`
);
}
if (cfg.levels > fileId.length) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
`cfg.levels (${cfg.levels}) exceeds fileId length (${fileId.length})`
);
}
if (cfg.padding < 0 || !Number.isInteger(cfg.padding)) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
`cfg.padding must be a non-negative integer; got ${cfg.padding}`
);
}
if (chunkId < 0 || !Number.isInteger(chunkId)) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
`chunkId must be a non-negative integer; got ${chunkId}`
);
}
const base = cfg.baseUrl.endsWith("/")
? cfg.baseUrl.slice(0, -1)
: cfg.baseUrl;
const reversed = reverseString(fileId);
const segments: string[] = [];
for (let i = 0; i < cfg.levels; i++) {
segments.push(reversed.charAt(i));
}
const padded = chunkId.toString().padStart(cfg.padding, "0");
const dirs = segments.length === 0 ? "" : `/${segments.join("/")}`;
return `${base}${dirs}/${fileId}_${padded}.chunk`;
}
function reverseString(input: string): string {
let out = "";
for (let i = input.length - 1; i >= 0; i--) {
out += input.charAt(i);
}
return out;
}

View File

@@ -0,0 +1,50 @@
# canonical
## Purpose
Implements `canonicalSerialize`, the deterministic byte-level encoding that gates every server signature on a `DriveTemplate` (DTP-03). Two implementations of the function MUST produce byte-identical output for identical input regardless of in-memory key order. JSON is deliberately rejected because object-key ordering is not portable across runtimes.
## Features
- Hand-rolled big-endian binary writer (`ByteWriter`) with `uint8` / `uint16` / `uint32` / `uint64` / `bool` / `string` / `bytes` primitives plus length-prefixed `optional*` helpers.
- Fixed field order across the entire template tree — `schemaVersion`, `driveId`, `issuer`, `issuedAt`, `expiresAt`, `nonce`, `policy`, `cdnFallback`, `rootFolder`.
- Recursive folder/file walker that tags each node with `TAG_FOLDER` (0x01) / `TAG_FILE` (0x02) so the decoder can reconstruct the tree without a separate manifest.
- Encryption-aware: `inline` and `retroactive` `KeyDistribution` variants are tagged 1 / 2 respectively; the `wrappedKey` blob and `keyAuthority` string follow.
- Excludes `serverSignature` — the bytes the issuer signs MUST not include the signature itself.
## Usage: Basic Example
```ts
import { canonicalSerialize } from "@suaveplan/drive-template";
const bytes = canonicalSerialize({
schemaVersion: 1,
driveId: "drive-1",
rootFolder: { name: "/", children: [] },
policy: { defaultPolicy: { autoDownload: false, meshAllowed: true, cdnAllowed: true } },
issuer: "00".repeat(32),
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000,
nonce: new Uint8Array(16).fill(7),
});
```
## Usage: Advanced Example
```ts
import { canonicalSerialize, signTemplate } from "@suaveplan/drive-template";
// canonicalSerialize is the inputs-side; signTemplate runs Ed25519 over it.
const tpl = signTemplate(buildTemplate(), serverSecretKey);
const recanon = canonicalSerialize({ ...tpl, serverSignature: undefined as never });
console.log(`signed bytes: ${recanon.length}`);
```
## API Reference
- `canonicalSerialize(template: Omit<DriveTemplate, "serverSignature">): Uint8Array`.
- Internal-only `ByteWriter` class with explicit primitives — not exported.
## Implementation Notes
The `ByteWriter` accumulates `Uint8Array` parts and concatenates once at `finish()` to avoid quadratic growth. `optionalUint64` / `optionalUint32` / `optionalString` write a discriminator byte first so receivers know whether the field follows; this trades two bytes per optional for unambiguous parsing. The retention policy is encoded as a tag (`0` = unset, `1` = `permanent`, `2` = `lru`, `3` = `{ maxAgeMs }`) so future variants can extend the union without breaking older readers — they will see an unknown tag and reject the template through the schema-version pin.

View File

@@ -0,0 +1,222 @@
/**
* Deterministic canonical serialization for `DriveTemplate` (DTP-03).
*
* Wire format is a hand-rolled big-endian binary stream with fixed
* field order so two implementations of `canonicalSerialize` produce
* byte-identical output for identical input regardless of in-memory
* key ordering. JSON is deliberately avoided because object-key order
* is not portable across runtimes.
*
* The serialization excludes the `serverSignature` field — it covers
* exactly the bytes that the issuer signs at template-creation time.
*/
import type {
CdnFallbackConfig,
DriveTemplate,
FileEncryption,
FileTemplate,
FolderTemplate,
GlobalPolicy,
KeyDistribution,
PerFilePolicy,
RetentionPolicy,
} from "../types/template.types.js";
const TAG_FOLDER = 0x01;
const TAG_FILE = 0x02;
/** The bytes the issuer signs. */
export function canonicalSerialize(
template: Omit<DriveTemplate, "serverSignature">
): Uint8Array {
const w = new ByteWriter();
w.uint16(template.schemaVersion);
w.string(template.driveId);
w.string(template.issuer);
w.uint64(BigInt(template.issuedAt));
w.uint64(BigInt(template.expiresAt));
w.bytes(template.nonce);
writeGlobalPolicy(w, template.policy);
writeOptionalCdn(w, template.cdnFallback);
writeFolder(w, template.rootFolder);
return w.finish();
}
function writeGlobalPolicy(w: ByteWriter, p: GlobalPolicy): void {
writePerFilePolicy(w, p.defaultPolicy);
w.optionalUint64(
p.maxLocalCacheBytes !== undefined
? BigInt(p.maxLocalCacheBytes)
: undefined
);
w.optionalUint32(p.maxConcurrentFetches);
const trusted = p.trustedKeyAuthorities ?? [];
w.uint32(trusted.length);
for (const k of trusted) w.string(k);
}
function writePerFilePolicy(w: ByteWriter, p: PerFilePolicy): void {
w.bool(p.autoDownload);
w.bool(p.meshAllowed);
w.bool(p.cdnAllowed);
writeRetention(w, p.retention);
}
function writeRetention(w: ByteWriter, r: RetentionPolicy | undefined): void {
if (r === undefined) {
w.uint8(0);
return;
}
if (r === "permanent") {
w.uint8(1);
return;
}
if (r === "lru") {
w.uint8(2);
return;
}
w.uint8(3);
w.uint64(BigInt(r.maxAgeMs));
}
function writeOptionalCdn(
w: ByteWriter,
c: CdnFallbackConfig | undefined
): void {
if (c === undefined) {
w.bool(false);
return;
}
w.bool(true);
w.string(c.baseUrl);
w.string(c.pathScheme);
w.uint32(c.levels);
w.uint32(c.padding);
w.optionalString(c.uploadEndpoint);
}
function writeFolder(w: ByteWriter, f: FolderTemplate): void {
w.uint8(TAG_FOLDER);
w.string(f.name);
w.uint32(f.children.length);
for (const child of f.children) {
if ("children" in child) {
writeFolder(w, child);
} else {
writeFile(w, child);
}
}
}
function writeFile(w: ByteWriter, f: FileTemplate): void {
w.uint8(TAG_FILE);
w.string(f.name);
w.string(f.fileLocId);
w.string(f.fileId);
w.uint64(BigInt(f.size));
w.uint32(f.totalChunks);
w.uint32(f.chunkSize);
w.string(f.checksum);
w.string(f.manifestKey);
w.bytes(f.manifestSig);
writePerFilePolicy(w, f.flags);
writeOptionalEncryption(w, f.encryption);
}
function writeOptionalEncryption(
w: ByteWriter,
e: FileEncryption | undefined
): void {
if (e === undefined) {
w.bool(false);
return;
}
w.bool(true);
w.string(e.algorithm);
writeKeyDistribution(w, e.keyDistribution);
}
function writeKeyDistribution(w: ByteWriter, k: KeyDistribution): void {
if (k.kind === "inline") {
w.uint8(1);
w.bytes(k.wrappedKey);
return;
}
w.uint8(2);
w.string(k.keyAuthority);
}
class ByteWriter {
#parts: Uint8Array[] = [];
#len = 0;
uint8(n: number): void {
const b = new Uint8Array(1);
b[0] = n & 0xff;
this.#push(b);
}
bool(v: boolean): void {
this.uint8(v ? 1 : 0);
}
uint16(n: number): void {
const b = new Uint8Array(2);
new DataView(b.buffer).setUint16(0, n, false);
this.#push(b);
}
uint32(n: number): void {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n >>> 0, false);
this.#push(b);
}
uint64(n: bigint): void {
const b = new Uint8Array(8);
new DataView(b.buffer).setBigUint64(0, n, false);
this.#push(b);
}
optionalUint32(n: number | undefined): void {
this.bool(n !== undefined);
if (n !== undefined) this.uint32(n);
}
optionalUint64(n: bigint | undefined): void {
this.bool(n !== undefined);
if (n !== undefined) this.uint64(n);
}
optionalString(s: string | undefined): void {
this.bool(s !== undefined);
if (s !== undefined) this.string(s);
}
string(s: string): void {
const enc = new TextEncoder().encode(s);
this.uint32(enc.length);
this.#push(enc);
}
bytes(b: Uint8Array): void {
this.uint32(b.length);
this.#push(b);
}
finish(): Uint8Array {
const out = new Uint8Array(this.#len);
let off = 0;
for (const part of this.#parts) {
out.set(part, off);
off += part.length;
}
return out;
}
#push(b: Uint8Array): void {
this.#parts.push(b);
this.#len += b.length;
}
}

View File

@@ -0,0 +1,3 @@
export { canonicalSerialize } from "./canonical.js";
export { signTemplate } from "./sign.js";
export { decodeIssuerKey, verifyTemplateSignature } from "./verify.js";

View File

@@ -0,0 +1,59 @@
# sign
## Purpose
Server-side signing helper that turns an unsigned `DriveTemplate` into a fully-formed envelope (DTP-04). Produces an Ed25519 signature over `canonicalSerialize(template)` and returns a new template object with the `serverSignature` field populated. The function never fabricates timestamps or nonces; the caller is responsible for supplying a fresh 16-byte nonce, a recent `issuedAt`, and an `expiresAt` window inside the 24-hour issuance budget.
## Features
- `signTemplate(template, serverSecretKey)` — pure function; no global state.
- Built on the deterministic `canonicalSerialize` byte stream so the signed payload is portable across runtimes.
- Returns a frozen-shape `DriveTemplate` ready to broadcast on the bus or persist to the FileLocationIndex.
## Usage: Basic Example
```ts
import {
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { signTemplate } from "@suaveplan/drive-template";
const secret = randomPrivateKey();
const tpl = signTemplate(
{
schemaVersion: 1,
driveId: "drive-1",
rootFolder: { name: "/", children: [] },
policy: { defaultPolicy: { autoDownload: false, meshAllowed: true, cdnAllowed: true } },
issuer: "00".repeat(32),
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000,
nonce: new Uint8Array(16).fill(7),
},
secret
);
```
## Usage: Advanced Example
```ts
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { toHex } from "@suaveplan/crypto-utils";
import { signTemplate, verifyTemplateSignature } from "@suaveplan/drive-template";
const secret = randomPrivateKey();
const issuer = toHex(getPublicKey(secret));
const tpl = signTemplate({ /* ... */ issuer } as never, secret);
verifyTemplateSignature(tpl); // throws on any tampering
```
## API Reference
- `signTemplate(template: Omit<DriveTemplate, "serverSignature">, serverSecretKey: Uint8Array): DriveTemplate`.
## Implementation Notes
`signTemplate` is intentionally synchronous — `@noble/curves` Ed25519 is fast enough that a 4096-byte template signs in well under a millisecond on commodity hardware, and the async overhead would dwarf the actual work. Callers that need to sign in a worker thread can wrap in `queueMicrotask` themselves; baking that into the API would force every consumer through an unnecessary Promise. The sign path performs zero memory allocations beyond the canonical buffer and the resulting signature, which keeps it suitable for high-throughput template-issuance pipelines that mint thousands of distributions per minute.

View File

@@ -0,0 +1,26 @@
/**
* Server-side signing helper for `DriveTemplate` (DTP-04).
*
* Produces an Ed25519 signature over `canonicalSerialize(template)`,
* where `template` excludes the `serverSignature` field. Callers MUST
* already have a freshly-generated 16-byte `nonce` and the
* `issuedAt` / `expiresAt` window populated; this module does not
* fabricate timestamps.
*/
import { sign } from "@suaveplan/crypto-curve/ed25519";
import type { DriveTemplate } from "../types/template.types.js";
import { canonicalSerialize } from "./canonical.js";
/**
* Sign a template with the issuer's Ed25519 secret key and return a
* complete `DriveTemplate` with `serverSignature` populated.
*/
export function signTemplate(
template: Omit<DriveTemplate, "serverSignature">,
serverSecretKey: Uint8Array
): DriveTemplate {
const canonical = canonicalSerialize(template);
const serverSignature = sign(canonical, serverSecretKey);
return { ...template, serverSignature };
}

View File

@@ -0,0 +1,51 @@
# verify
## Purpose
Server-signature verification for `DriveTemplate` (DTP-04). Validates that `template.serverSignature` is a correct Ed25519 signature over `canonicalSerialize(template - sig)` under the issuer's public key (interpreted as a hex string and decoded to a 32-byte buffer). Tampering with any field — including `nonce`, `issuedAt`, or any nested folder/file — causes verification to fail.
## Features
- `verifyTemplateSignature(template)` — throws on mismatch or invalid input.
- `decodeIssuerKey(hex)` — exposed helper that wraps `@suaveplan/crypto-utils.fromHex` so consumers see `DRIVE.TEMPLATE_SIG_INVALID` instead of a raw decode error.
- Trusts `@noble/curves` to return `false` on malformed signatures (no separate try/catch).
## Usage: Basic Example
```ts
import { verifyTemplateSignature } from "@suaveplan/drive-template";
verifyTemplateSignature(template);
// throws DriveTemplateError(DRIVE.TEMPLATE_SIG_INVALID) on any tampering
```
## Usage: Advanced Example
```ts
import {
DTP_CODES,
DriveTemplateError,
verifyTemplateSignature,
} from "@suaveplan/drive-template";
function ingestPeerTemplate(tpl: import("@suaveplan/drive-template").DriveTemplate) {
try {
verifyTemplateSignature(tpl);
} catch (err) {
if (err instanceof DriveTemplateError && err.code === DTP_CODES.TEMPLATE_SIG_INVALID) {
return reportTamperedTemplate(tpl.driveId);
}
throw err;
}
persist(tpl);
}
```
## API Reference
- `verifyTemplateSignature(template: DriveTemplate): true` — returns `true` on success; throws `DriveTemplateError(DRIVE.TEMPLATE_SIG_INVALID)` otherwise.
- `decodeIssuerKey(issuerHex: string): Uint8Array` — throws `DriveTemplateError(DRIVE.TEMPLATE_SIG_INVALID)` on malformed hex.
## Implementation Notes
The verifier rebuilds `withoutSig` by destructuring out the `serverSignature` field rather than zeroing it in place; this matches the encoder's behaviour precisely and avoids any subtle aliasing bugs across runtimes. The function returns `true` on success rather than `void` so the type system can express "this template has been verified" downstream — the codebase pairs this with the `OpaqueKeyHandle` brand pattern used in `@suaveplan/vault` for the same effect. There is no async path; templates verify in well under a millisecond on commodity hardware so the synchronous API stays branch-free.

View File

@@ -0,0 +1,78 @@
/**
* Server-signature verification for `DriveTemplate` (DTP-04).
*
* Validates that `template.serverSignature` is a correct Ed25519
* signature over `canonicalSerialize(template - sig)` under
* `template.issuer` (interpreted as a hex-encoded Ed25519 pubkey).
* Tampering with any field — including `nonce`, `issuedAt`, or any
* nested folder/file — causes verification to fail.
*/
import { verify } from "@suaveplan/crypto-curve/ed25519";
import { fromHex } from "@suaveplan/crypto-utils";
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { DriveTemplate } from "../types/template.types.js";
import { canonicalSerialize } from "./canonical.js";
/**
* Decode a hex-encoded Ed25519 pubkey string to its 32-byte form.
*
* Wraps `@suaveplan/crypto-utils.hexToBytes` so callers see a
* `DRIVE.TEMPLATE_SIG_INVALID` rather than a raw decode error.
*/
export function decodeIssuerKey(issuerHex: string): Uint8Array {
try {
return fromHex(issuerHex);
} catch (cause) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_SIG_INVALID,
`issuer pubkey is not valid hex: ${String(cause)}`,
{ cause: cause as Error }
);
}
}
/** Signature-verifier injection point so tests can exercise the catch arm. */
export type VerifyEd25519Fn = (
sig: Uint8Array,
msg: Uint8Array,
pub: Uint8Array
) => boolean;
/**
* Verify the server signature on a complete `DriveTemplate`.
* Returns `true` on success; throws `DriveTemplateError` on failure.
*
* `verifyFn` is injectable so tests can pass a throwing implementation
* to exercise the forward-defensive catch arm; defaults to the noble
* Ed25519 verifier from `@suaveplan/crypto-curve/ed25519`. `@noble/curves`
* 1.9.x returns `false` on every malformed input we have constructed
* empirically; the catch ensures a future throw still surfaces as the
* spec-mandated `DRIVE.TEMPLATE_SIG_INVALID` rather than an uncoded
* runtime error.
*/
export function verifyTemplateSignature(
template: DriveTemplate,
verifyFn: VerifyEd25519Fn = verify
): true {
const { serverSignature, ...withoutSig } = template;
const issuerKey = decodeIssuerKey(template.issuer);
const canonical = canonicalSerialize(withoutSig);
let ok = false;
try {
ok = verifyFn(serverSignature, canonical, issuerKey);
} catch (cause) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_SIG_INVALID,
`Ed25519 verification raised: ${String(cause)}`,
{ cause: cause as Error }
);
}
if (!ok) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_SIG_INVALID,
"server signature does not verify against canonicalSerialize(template - sig)"
);
}
return true;
}

View File

@@ -0,0 +1,95 @@
# DTP_CODES + DriveTemplateError
## Purpose
Frozen registry of error codes plus the error class for the `@suaveplan/drive-template` package. Eleven codes cover the complete failure surface: the eight-step validation pipeline that `Drive.hydrateFrom(template)` runs (version check, expiry, freshness, replay, issuer trust, signature, recursion depth, file consistency) plus three operational codes for CDN signature failure, inline-file signature failure, and key-grant rejection. Consumers branch on `code` programmatically; messages are for humans only.
## Features
- Eleven codes mapping one-to-one to the eight-step pipeline plus three operational paths.
- `DTP_CODES` is frozen via `defineCodes` from `@suaveplan/error` — adding a code requires a code change and a spec update.
- `DriveTemplateError extends SuaveplanError<DtpErrorCode>` — inherits cause-chain traversal, redaction, and JSON wire format.
- Type-level exhaustiveness on switch statements via the literal-string `DtpErrorCode` union.
## Usage: Basic Example
```ts
import { DriveTemplateError, DTP_CODES } from "@suaveplan/drive-template/errors";
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_EXPIRED,
"template expired at 2026-01-01T00:00:00Z",
);
```
## Usage: Advanced Example
```ts
import {
DriveTemplateError,
DTP_CODES,
type DtpErrorCode,
} from "@suaveplan/drive-template/errors";
const remediation: Record<DtpErrorCode, string> = {
"DTP.TEMPLATE_VERSION_UNSUPPORTED": "Update the client; this template uses a newer schema.",
"DTP.TEMPLATE_EXPIRED": "Request a fresh template from the issuer.",
"DTP.TEMPLATE_STALE": "Request a fresh template; this one is more than 24h old.",
"DTP.TEMPLATE_REPLAY": "Template nonce already seen; the issuer must mint a new one.",
"DTP.TEMPLATE_UNTRUSTED_ISSUER": "Add the issuer to globalPolicy.trustedKeyAuthorities.",
"DTP.TEMPLATE_SIG_INVALID": "Template signature does not match the canonical bytes; possible tampering.",
"DTP.TEMPLATE_TOO_DEEP": "Folder recursion exceeds the 32-level cap.",
"DTP.TEMPLATE_MALFORMED": "Per-file consistency violated; check chunkSize, totalChunks, and manifest fields.",
"DTP.CDN_BAD_SIGNATURE": "CDN response failed Ed25519 verification; falling back to mesh.",
"DTP.INLINE_SIG_INVALID": "Inline file's stored signature does not match the bytes.",
"DTP.KEY_GRANT_REJECTED": "Key grant signature, recipient, or issuer did not match the template.",
};
try {
// hydrate code...
} catch (err) {
if (err instanceof DriveTemplateError) {
console.error(`[${err.code}] ${err.message}`);
console.error("Remediation:", remediation[err.code]);
}
}
```
## API Reference
### `const DTP_CODES`
Frozen object mapping uppercase code names to wire-format strings. Eleven entries:
**Eight-step validation pipeline** (matches `DTP-05` ordering):
- `TEMPLATE_VERSION_UNSUPPORTED` — `template.schemaVersion !== 1`. Future schema versions ship as new packages.
- `TEMPLATE_EXPIRED` — `Date.now() >= template.expiresAt`.
- `TEMPLATE_STALE` — `Date.now() - template.issuedAt > 24h`. Templates issued more than 24h ago are rejected even if not yet expired.
- `TEMPLATE_REPLAY` — `(driveId, nonce)` already seen in the local LRU cache (last 1024 entries).
- `TEMPLATE_UNTRUSTED_ISSUER` — `template.issuer` is not in `globalPolicy.trustedKeyAuthorities`.
- `TEMPLATE_SIG_INVALID` — Ed25519 verification of `serverSignature` over `canonicalSerialize(template - sig)` failed.
- `TEMPLATE_TOO_DEEP` — folder recursion exceeds 32 levels.
- `TEMPLATE_MALFORMED` — per-file consistency violated: `chunkSize <= 164`, `totalChunks * chunkSize < size`, `manifestKey` empty, etc.
**Operational**:
- `CDN_BAD_SIGNATURE` — chunk fetched from CDN failed Ed25519 sig verification. Drive falls back to mesh.
- `INLINE_SIG_INVALID` — inline file's stored Ed25519 signature does not match `Ed25519-sign(driveSecretKey, fileId ‖ bytes)`.
- `KEY_GRANT_REJECTED` — incoming `KeyGrantEnvelope` failed validation: issuer mismatch, recipient mismatch, sig invalid, or expired.
### `type DtpErrorCode`
Union: `(typeof DTP_CODES)[keyof typeof DTP_CODES]`.
### `class DriveTemplateError extends SuaveplanError<DtpErrorCode>`
Constructor: `new DriveTemplateError(code, message, options?)`. Inherits all `SuaveplanError` features.
## Implementation Notes
The eight pipeline codes are ordered by check sequence in `DTP-05`. Implementations MUST throw the first applicable code — they are mutually exclusive in a single hydrate call. The three operational codes are independent: a single drive may emit multiple `CDN_BAD_SIGNATURE` and `INLINE_SIG_INVALID` errors during normal operation; both are observable signals, not blockers.
`KEY_GRANT_REJECTED` is the only error code whose `context` field carries structured detail by convention. Implementations should populate `context` with `{ reason: 'untrusted-issuer' | 'wrong-recipient' | 'bad-signature' | 'expired' }` so callers can branch on the specific failure.
The `error-codes.spec-conformance.test.ts` enforces (1) the registry is frozen, (2) every code value starts with `DTP.`, (3) the eleven names match what the spec requires.

View File

@@ -0,0 +1,47 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen registry of error codes for the drive-template package.
*
* Codes map one-to-one to the eight-step validation pipeline that
* `Drive.hydrateFrom(template)` runs (per `DTP-05` in the
* `drive-template-v1` capability spec) plus two operational codes
* (CDN signature failure, key-grant rejection). The `INLINE_SIG_INVALID`
* code documented in the same change folder lives on
* `@suaveplan/drive-plugin-chunks` rather than here, because the
* inline-storage-hint surface is owned by that package.
*/
export const DTP_CODES = defineCodes("DTP", [
// 8-step hydration validation pipeline
"TEMPLATE_VERSION_UNSUPPORTED",
"TEMPLATE_EXPIRED",
"TEMPLATE_STALE",
"TEMPLATE_REPLAY",
"TEMPLATE_UNTRUSTED_ISSUER",
"TEMPLATE_SIG_INVALID",
"TEMPLATE_TOO_DEEP",
"TEMPLATE_MALFORMED",
// Operational
"CDN_BAD_SIGNATURE",
"KEY_GRANT_REJECTED",
] as const);
/** Union of every code in `DTP_CODES`. */
export type DtpErrorCode = (typeof DTP_CODES)[keyof typeof DTP_CODES];
/**
* Error class for the drive-template package. Extends `SuaveplanError`
* so consumers can `instanceof DriveTemplateError` and access `code`,
* `cause`, `context`, and the structured wire-format helpers.
*/
export class DriveTemplateError extends SuaveplanError<DtpErrorCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: DtpErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1,65 @@
// Side-effect import ensures DTP_CODES is registered for subpath consumers.
import "./error-codes.js";
export {
buildCdnChunkUrl,
canonicalSerializeUploadToken,
DEFAULT_CDN_LEVELS,
DEFAULT_CDN_PADDING,
signUploadToken,
UPLOAD_TOKEN_TTL_DEFAULT_MS,
UPLOAD_TOKEN_TTL_MAX_MS,
verifyUploadToken,
} from "./cdn/index.js";
export {
canonicalSerialize,
decodeIssuerKey,
signTemplate,
verifyTemplateSignature,
} from "./codec/index.js";
export {
DriveTemplateError,
DTP_CODES,
type DtpErrorCode,
} from "./error-codes.js";
export {
canonicalSerializeKeyGrant,
type ValidateKeyGrantOptions,
validateKeyGrant,
} from "./key/index.js";
export {
CdnFallbackConfigSchema,
CdnUploadTokenSchema,
DriveTemplateSchema,
FileEncryptionSchema,
FileTemplateSchema,
FolderTemplateSchema,
GlobalPolicySchema,
KeyGrantPayloadSchema,
} from "./schemas/index.js";
export type {
CdnFallbackConfig,
CdnPathScheme,
CdnUploadToken,
DriveTemplate,
FileEncryption,
FileTemplate,
FolderTemplate,
GlobalPolicy,
KeyDistribution,
KeyGrantPayload,
PerFilePolicy,
RetentionPolicy,
} from "./types/template.types.js";
export {
assertFile,
assertFolderDepth,
assertFolderFileInvariants,
ISSUANCE_WINDOW_MS,
MAX_FOLDER_DEPTH,
MIN_CHUNK_SIZE_EXCLUSIVE,
TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY,
TemplateNonceLru,
type ValidationContext,
validateTemplate,
} from "./validate/index.js";

View File

@@ -0,0 +1,63 @@
# grant-validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `validateKeyGrant`, the receiver-side validator for retroactive `KeyGrantEnvelope` payloads (DTP-21). Validates each grant in the spec-mandated order — issuer ≡ template's `keyAuthority`, recipient ≡ our pubkey, Ed25519 signature, optional expiry — before the drive unwraps the wrapped key.
## Features
- `validateKeyGrant(grant, options)` — single-call validator returning `true` on success.
- `canonicalSerializeKeyGrant(grant)` — deterministic byte-level encoding shared by issuer and verifier.
- Strict failure ordering so the highest-trust check (issuer authority) runs first; cheap checks fail fast.
- Trusts `@noble/curves` to return `false` for malformed signatures (no try/catch).
## Usage: Basic Example
```ts
import { validateKeyGrant } from "@suaveplan/drive-template";
validateKeyGrant(grant, {
expectedKeyAuthority: template.encryption.keyAuthority,
ourPubkey: peerId,
clock: () => Date.now(),
});
```
## Usage: Advanced Example
```ts
import {
DTP_CODES,
DriveTemplateError,
validateKeyGrant,
} from "@suaveplan/drive-template";
function onKeyGrantReceived(grant, expectedKeyAuthority, ourPubkey) {
try {
validateKeyGrant(grant, {
expectedKeyAuthority,
ourPubkey,
clock: () => Date.now(),
});
} catch (err) {
if (err instanceof DriveTemplateError && err.code === DTP_CODES.KEY_GRANT_REJECTED) {
return reportSuspiciousGrant(grant);
}
throw err;
}
return unwrapAndStore(grant.wrappedKey, grant.fileId);
}
```
## API Reference
- `validateKeyGrant(grant: KeyGrantPayload, options: ValidateKeyGrantOptions): true` — throws `DriveTemplateError(DRIVE.KEY_GRANT_REJECTED)` on any failure.
- `interface ValidateKeyGrantOptions` — `{ expectedKeyAuthority: PeerId; ourPubkey: PeerId; clock: Clock }`.
- `canonicalSerializeKeyGrant(grant: Omit<KeyGrantPayload, "issuerSignature">): Uint8Array`.
## Implementation Notes
The expiry check fires AFTER the signature check so an expired-but-otherwise-valid grant still produces the correct rejection code (`KEY_GRANT_REJECTED`) without ever exposing the unwrapped key. The four-step order (issuer / recipient / signature / expiry) matches DTP-21's prose exactly so spec drift would surface as a test failure rather than a subtle behavioural shift. The canonical-serialize helper writes a length-prefixed wrapped-key buffer plus the optional `expiresAt` field (encoded as zero when absent so the wire bytes stay stable across implementations); issuer-side signing code MUST traverse the same canonical encoder for verification to succeed downstream.

View File

@@ -0,0 +1,137 @@
/**
* `KeyGrantEnvelope` validator (DTP-21).
*
* Retroactive key distribution arrives via the `key:grant` bus topic.
* The drive validates each grant in this exact order before unwrapping
* the key:
*
* 1. `issuer === template.encryption.keyAuthority` (per-file).
* 2. `recipient === ourPubkey`.
* 3. `Ed25519-verify(issuer, issuerSignature, canonicalGrant)`.
* 4. `expiresAt > now` (if present).
*/
import { verify } from "@suaveplan/crypto-curve/ed25519";
import { fromHex } from "@suaveplan/crypto-utils";
import type { Clock, PeerId } from "@suaveplan/types";
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { KeyGrantPayload } from "../types/template.types.js";
/** The bytes covered by `KeyGrantPayload.issuerSignature`. */
export function canonicalSerializeKeyGrant(
grant: Omit<KeyGrantPayload, "issuerSignature">
): Uint8Array {
const enc = new TextEncoder();
const fileId = enc.encode(grant.fileId);
const recipient = enc.encode(grant.recipient);
const issuer = enc.encode(grant.issuer);
const out = new Uint8Array(
4 +
fileId.length +
4 +
recipient.length +
4 +
issuer.length +
4 +
grant.wrappedKey.length +
8
);
let off = 0;
off = writeU32(out, off, fileId.length);
out.set(fileId, off);
off += fileId.length;
off = writeU32(out, off, recipient.length);
out.set(recipient, off);
off += recipient.length;
off = writeU32(out, off, issuer.length);
out.set(issuer, off);
off += issuer.length;
off = writeU32(out, off, grant.wrappedKey.length);
out.set(grant.wrappedKey, off);
off += grant.wrappedKey.length;
new DataView(out.buffer, out.byteOffset, out.byteLength).setBigUint64(
off,
BigInt(grant.expiresAt ?? 0),
false
);
return out;
}
function writeU32(buf: Uint8Array, off: number, value: number): number {
new DataView(buf.buffer, buf.byteOffset, buf.byteLength).setUint32(
off,
value >>> 0,
false
);
return off + 4;
}
export interface ValidateKeyGrantOptions {
readonly expectedKeyAuthority: PeerId;
readonly ourPubkey: PeerId;
readonly clock: Clock;
/** Verifier injection point so tests can exercise the catch arm. */
readonly verifyFn?: (
sig: Uint8Array,
msg: Uint8Array,
pub: Uint8Array
) => boolean;
}
export function validateKeyGrant(
grant: KeyGrantPayload,
options: ValidateKeyGrantOptions
): true {
if (grant.issuer !== options.expectedKeyAuthority) {
throw new DriveTemplateError(
DTP_CODES.KEY_GRANT_REJECTED,
`key grant issuer ${grant.issuer} does not match expected keyAuthority ${options.expectedKeyAuthority}`
);
}
if (grant.recipient !== options.ourPubkey) {
throw new DriveTemplateError(
DTP_CODES.KEY_GRANT_REJECTED,
`key grant recipient ${grant.recipient} is not us (${options.ourPubkey})`
);
}
let issuerKey: Uint8Array;
try {
issuerKey = fromHex(grant.issuer);
} catch (cause) {
throw new DriveTemplateError(
DTP_CODES.KEY_GRANT_REJECTED,
`key grant issuer pubkey is not valid hex: ${String(cause)}`,
{ cause: cause as Error }
);
}
const { issuerSignature, ...rest } = grant;
const canonical = canonicalSerializeKeyGrant(rest);
// Forward-defensive catch — see codec/verify.ts for the AGENTS.md
// §7a justification. The verifier is injectable so tests can pass
// a throwing implementation to exercise the catch arm; in
// production it defaults to `@noble/curves`.
const verifyFn = options.verifyFn ?? verify;
let ok = false;
try {
ok = verifyFn(issuerSignature, canonical, issuerKey);
} catch (cause) {
throw new DriveTemplateError(
DTP_CODES.KEY_GRANT_REJECTED,
`Ed25519 verification raised: ${String(cause)}`,
{ cause: cause as Error }
);
}
if (!ok) {
throw new DriveTemplateError(
DTP_CODES.KEY_GRANT_REJECTED,
"key grant issuerSignature does not verify"
);
}
if (grant.expiresAt !== undefined && options.clock() >= grant.expiresAt) {
throw new DriveTemplateError(
DTP_CODES.KEY_GRANT_REJECTED,
`key grant expired at ${grant.expiresAt}`
);
}
return true;
}

View File

@@ -0,0 +1,5 @@
export {
canonicalSerializeKeyGrant,
type ValidateKeyGrantOptions,
validateKeyGrant,
} from "./grant-validator.js";

View File

@@ -0,0 +1,10 @@
export {
CdnFallbackConfigSchema,
CdnUploadTokenSchema,
DriveTemplateSchema,
FileEncryptionSchema,
FileTemplateSchema,
FolderTemplateSchema,
GlobalPolicySchema,
KeyGrantPayloadSchema,
} from "./template.schema.js";

View File

@@ -0,0 +1,60 @@
# template.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod boundary schemas for `@suaveplan/drive-template`. The codec / validate modules invoke `safeParse` on decoded JSON before any logic touches the payload, satisfying DTP-26's "schema validation before payload processing" mandate. The schemas mirror the wire types in `src/types/template.types.ts` exactly — adding a field to one without the other will fail compilation.
## Features
- `DriveTemplateSchema` — top-level template; refines `nonce` to exactly 16 bytes.
- `FolderTemplateSchema` — recursive via `z.lazy()`.
- `FileTemplateSchema` — every required field plus optional encryption.
- `GlobalPolicySchema` / `PerFilePolicySchema` / `RetentionPolicySchema` — policy tree.
- `CdnFallbackConfigSchema` — bounded `levels` (≤ 64), `padding` (≤ 32), URL-shaped `baseUrl`/`uploadEndpoint`.
- `CdnUploadTokenSchema` — token wire shape.
- `KeyGrantPayloadSchema` / `FileEncryptionSchema` — encryption / key-distribution wire shapes.
## Usage: Basic Example
```ts
import { DriveTemplateSchema } from "@suaveplan/drive-template";
const r = DriveTemplateSchema.safeParse(decodedFromJson);
if (!r.success) throw new Error(r.error.message);
const tpl = r.data;
```
## Usage: Advanced Example
```ts
import {
CdnFallbackConfigSchema,
DriveTemplateSchema,
FolderTemplateSchema,
} from "@suaveplan/drive-template";
function isValidCdn(input: unknown): boolean {
return CdnFallbackConfigSchema.safeParse(input).success;
}
function ingestFolder(folder: unknown) {
const r = FolderTemplateSchema.safeParse(folder);
if (!r.success) return { error: r.error.message };
return { folder: r.data };
}
const allTemplateFields = DriveTemplateSchema.shape;
```
## API Reference
- `DriveTemplateSchema`, `FolderTemplateSchema`, `FileTemplateSchema`.
- `GlobalPolicySchema`, `CdnFallbackConfigSchema`, `CdnUploadTokenSchema`.
- `FileEncryptionSchema`, `KeyGrantPayloadSchema`.
## Implementation Notes
`FolderTemplateSchema` uses Zod's `z.lazy()` to break the recursive type cycle; the cast to `FolderTemplateZodShape` is the canonical Zod pattern for self-referential schemas. The `nonce` refinement enforces exactly 16 bytes — the spec is precise here because shorter nonces are guessable and longer ones waste bus bandwidth. The `levels` upper bound (64) is a defence against accidental misconfiguration; real CDNs use 2–3 levels, so 64 is generous headroom but well below the point where a path explodes. Every object schema is `.strict()` so misspelt or extraneous fields fail validation rather than passing through silently.

View File

@@ -0,0 +1,138 @@
/**
* Zod boundary schemas for `@suaveplan/drive-template`. The codec /
* validate modules invoke `safeParse` before any logic touches the
* payload. The schemas mirror the wire types in
* `src/types/template.types.ts`.
*/
import * as z from "zod";
const HexString = z.string().regex(/^[0-9a-f]+$/i, "must be lowercase hex");
const RetentionPolicySchema = z.union([
z.literal("permanent"),
z.literal("lru"),
z.object({ maxAgeMs: z.number().int().nonnegative() }).strict(),
]);
const PerFilePolicySchema = z
.object({
autoDownload: z.boolean(),
meshAllowed: z.boolean(),
cdnAllowed: z.boolean(),
retention: RetentionPolicySchema.optional(),
})
.strict();
const CdnPathSchemeSchema = z.literal("reverse-id");
export const CdnFallbackConfigSchema = z
.object({
baseUrl: z.string().min(1).url(),
pathScheme: CdnPathSchemeSchema,
levels: z.number().int().nonnegative().max(64),
padding: z.number().int().nonnegative().max(32),
uploadEndpoint: z.string().url().optional(),
})
.strict();
const KeyDistributionSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("inline"),
wrappedKey: z.instanceof(Uint8Array),
})
.strict(),
z
.object({
kind: z.literal("retroactive"),
keyAuthority: z.string().min(1),
})
.strict(),
]);
export const FileEncryptionSchema = z
.object({
algorithm: z.literal("xchacha20-poly1305"),
keyDistribution: KeyDistributionSchema,
})
.strict();
export const FileTemplateSchema = z
.object({
name: z.string().min(1),
fileLocId: z.string().min(1),
fileId: z.string().min(1),
size: z.number().int().nonnegative(),
totalChunks: z.number().int().nonnegative(),
chunkSize: z.number().int().positive(),
checksum: z.string().regex(/^(?:blake3|sha256):[0-9a-f]+$/i),
flags: PerFilePolicySchema,
encryption: FileEncryptionSchema.optional(),
manifestKey: z.string().min(1),
manifestSig: z.instanceof(Uint8Array),
})
.strict();
// Folders are recursive; Zod's lazy() is the canonical pattern.
type FolderTemplateZodShape = z.ZodObject<{
name: z.ZodString;
children: z.ZodArray<
z.ZodUnion<[FolderTemplateZodShape, typeof FileTemplateSchema]>
>;
}>;
export const FolderTemplateSchema: FolderTemplateZodShape = z.lazy(() =>
z.object({
name: z.string().min(1),
children: z.array(z.union([FolderTemplateSchema, FileTemplateSchema])),
})
) as unknown as FolderTemplateZodShape;
export const GlobalPolicySchema = z
.object({
defaultPolicy: PerFilePolicySchema,
maxLocalCacheBytes: z.number().int().nonnegative().optional(),
maxConcurrentFetches: z.number().int().positive().optional(),
trustedKeyAuthorities: z.array(z.string().min(1)).optional(),
})
.strict();
export const DriveTemplateSchema = z
.object({
schemaVersion: z.literal(1),
driveId: z.string().min(1),
rootFolder: FolderTemplateSchema,
cdnFallback: CdnFallbackConfigSchema.optional(),
policy: GlobalPolicySchema,
issuer: HexString,
issuedAt: z.number().int().nonnegative(),
expiresAt: z.number().int().nonnegative(),
nonce: z
.instanceof(Uint8Array)
.refine((v) => v.length === 16, "nonce must be exactly 16 bytes"),
serverSignature: z.instanceof(Uint8Array),
})
.strict();
export const CdnUploadTokenSchema = z
.object({
fileId: z.string().min(1),
chunkId: z.number().int().nonnegative(),
chunkHash: z.string().min(1),
uploaderPubkey: z.string().min(1),
expiresAt: z.number().int().nonnegative(),
serverSignature: z.instanceof(Uint8Array),
})
.strict();
export const KeyGrantPayloadSchema = z
.object({
fileId: z.string().min(1),
recipient: z.string().min(1),
wrappedKey: z.instanceof(Uint8Array),
issuer: z.string().min(1),
issuerSignature: z.instanceof(Uint8Array),
expiresAt: z.number().int().nonnegative().optional(),
})
.strict();

View File

@@ -0,0 +1,14 @@
export type {
CdnFallbackConfig,
CdnPathScheme,
CdnUploadToken,
DriveTemplate,
FileEncryption,
FileTemplate,
FolderTemplate,
GlobalPolicy,
KeyDistribution,
KeyGrantPayload,
PerFilePolicy,
RetentionPolicy,
} from "./template.types.js";

View File

@@ -0,0 +1,65 @@
# template.types
## Purpose
Holds the cross-module type vocabulary for `@suaveplan/drive-template`: `DriveTemplate`, `FolderTemplate`, `FileTemplate`, `GlobalPolicy`, `PerFilePolicy`, `RetentionPolicy`, `CdnFallbackConfig`, `CdnUploadToken`, `FileEncryption`, `KeyDistribution`, `KeyGrantPayload`. Every wire-format payload, validator input, and codec option references a type from this file so the package shares a single language for template-shaped data without circular imports between feature directories.
## Features
- `DriveTemplate` — the dehydrated drive description with `serverSignature`.
- `FolderTemplate` / `FileTemplate` — recursive tree nodes.
- `PerFilePolicy` / `RetentionPolicy` — autoDownload / meshAllowed / cdnAllowed flags + retention enum.
- `GlobalPolicy` — defaults, cache budget, concurrency cap, trusted authorities.
- `CdnFallbackConfig` — reverse-id URL builder configuration.
- `CdnUploadToken` — server-signed CDN write authorisation.
- `FileEncryption` / `KeyDistribution` — `inline` vs `retroactive` key delivery.
- `KeyGrantPayload` — bus-borne retroactive grant envelope payload.
## Usage: Basic Example
```ts
import type {
DriveTemplate,
FolderTemplate,
} from "@suaveplan/drive-template";
declare const tpl: DriveTemplate;
declare const folder: FolderTemplate;
```
## Usage: Advanced Example
```ts
import type {
CdnUploadToken,
KeyGrantPayload,
PerFilePolicy,
RetentionPolicy,
} from "@suaveplan/drive-template";
function policyDefaults(): PerFilePolicy {
const retention: RetentionPolicy = { maxAgeMs: 30 * 24 * 60 * 60 * 1000 };
return { autoDownload: false, meshAllowed: true, cdnAllowed: true, retention };
}
function tokenLooksFresh(t: CdnUploadToken, now: number): boolean {
return t.expiresAt > now;
}
function isRetroactive(grant: KeyGrantPayload): boolean {
return grant.expiresAt === undefined || grant.expiresAt > Date.now();
}
```
## API Reference
- `interface DriveTemplate` — `{ schemaVersion: 1, driveId, rootFolder, cdnFallback?, policy, issuer, issuedAt, expiresAt, nonce, serverSignature }`.
- `interface FolderTemplate` — `{ name, children: ReadonlyArray<FolderTemplate | FileTemplate> }`.
- `interface FileTemplate` — `{ name, fileLocId, fileId, size, totalChunks, chunkSize, checksum, flags, encryption?, manifestKey, manifestSig }`.
- `interface PerFilePolicy`, `type RetentionPolicy`, `interface GlobalPolicy`.
- `interface CdnFallbackConfig`, `interface CdnUploadToken`, `type CdnPathScheme`.
- `interface FileEncryption`, `type KeyDistribution`, `interface KeyGrantPayload`.
## Implementation Notes
`schemaVersion` is a literal `1` to lock the shape — bumping schema versions ships as a new package (`@suaveplan/drive-template-v2`), never as in-place modifications. `nonce` is a 16-byte `Uint8Array` rather than a hex string so the wire format doesn't waste bytes on encoding overhead. `manifestSig` and `wrappedKey` are raw `Uint8Array` bufs for the same reason. Every field is `readonly` so consumers can't accidentally mutate a verified template — once `verifyTemplateSignature` returns `true`, the template is treated as immutable through the rest of the hydration pipeline.

View File

@@ -0,0 +1,129 @@
/**
* Drive-template wire types.
*
* Mirrors the structural contract pinned in
* `openspec/changes/drive-template-v1/specs/drive-template/spec.md`.
* All types are `interface` or `type` — zero runtime — and compose with
* cross-cutting types from `@suaveplan/types`.
*/
import type { PeerId } from "@suaveplan/types";
/** Per-file retention policy. */
export type RetentionPolicy =
| "permanent"
| "lru"
| { readonly maxAgeMs: number };
/** Per-file policy flags carried in `FileTemplate.flags`. */
export interface PerFilePolicy {
/** Eagerly fetch the file's chunks on hydrate. */
readonly autoDownload: boolean;
/** Permit `cd:request` emission for this file's chunks. */
readonly meshAllowed: boolean;
/** Permit `drive-transport-https` fallback for this file's chunks. */
readonly cdnAllowed: boolean;
/** Cache eviction strategy. Defaults to `globalPolicy.defaultPolicy.retention`. */
readonly retention?: RetentionPolicy;
}
/** Path scheme for the CDN URL builder. */
export type CdnPathScheme = "reverse-id";
/** Configuration for the CDN HTTPS fallback. */
export interface CdnFallbackConfig {
/** Base URL — e.g. `"https://chunks.example.com"`. No trailing slash. */
readonly baseUrl: string;
/** Path scheme. Currently only `"reverse-id"` is defined. */
readonly pathScheme: CdnPathScheme;
/** Directory depth for the reversed-ID layout. Default 3. */
readonly levels: number;
/** Chunk-id zero-pad width. Default 9. */
readonly padding: number;
/** Optional write-back endpoint for `CdnUploadToken` POSTs. */
readonly uploadEndpoint?: string;
}
/** Server-signed token authorising a CDN write. */
export interface CdnUploadToken {
readonly fileId: string;
readonly chunkId: number;
/** BLAKE3 of the chunk bytes; CDN verifies before storing. */
readonly chunkHash: string;
readonly uploaderPubkey: PeerId;
readonly expiresAt: number;
readonly serverSignature: Uint8Array;
}
/** Distribution mode for a file's encryption key. */
export type KeyDistribution =
| { readonly kind: "inline"; readonly wrappedKey: Uint8Array }
| { readonly kind: "retroactive"; readonly keyAuthority: PeerId };
/** Per-file encryption configuration. */
export interface FileEncryption {
readonly algorithm: "xchacha20-poly1305";
readonly keyDistribution: KeyDistribution;
}
/** Single-file metadata stub in a template. */
export interface FileTemplate {
readonly name: string;
readonly fileLocId: string;
readonly fileId: string;
/** Plaintext byte count. */
readonly size: number;
readonly totalChunks: number;
readonly chunkSize: number;
/** `'blake3:<hex>'` or `'sha256:<hex>'`. */
readonly checksum: string;
readonly flags: PerFilePolicy;
readonly encryption?: FileEncryption;
/** Storage key where the chunk manifest is stored. */
readonly manifestKey: string;
/** Detached Ed25519 signature over the manifest. */
readonly manifestSig: Uint8Array;
}
/** Recursive folder node in a template. */
export interface FolderTemplate {
readonly name: string;
readonly children: ReadonlyArray<FolderTemplate | FileTemplate>;
}
/** Mesh-wide defaults for files whose policy fields are absent. */
export interface GlobalPolicy {
readonly defaultPolicy: PerFilePolicy;
/** Cap for the global LRU eviction. */
readonly maxLocalCacheBytes?: number;
/** Cap for parallel hydration fetches. */
readonly maxConcurrentFetches?: number;
/** Pubkeys whose `key:grant` envelopes the drive will accept. */
readonly trustedKeyAuthorities?: ReadonlyArray<PeerId>;
}
/** Server-signed drive template — the dehydrated drive description. */
export interface DriveTemplate {
readonly schemaVersion: 1;
readonly driveId: string;
readonly rootFolder: FolderTemplate;
readonly cdnFallback?: CdnFallbackConfig;
readonly policy: GlobalPolicy;
readonly issuer: PeerId;
readonly issuedAt: number;
readonly expiresAt: number;
/** 16-byte replay-protection nonce. */
readonly nonce: Uint8Array;
/** Ed25519 signature over `canonicalSerialize(template - sig)`. */
readonly serverSignature: Uint8Array;
}
/** Decoded payload of a `key:grant` bus envelope. */
export interface KeyGrantPayload {
readonly fileId: string;
readonly recipient: PeerId;
readonly wrappedKey: Uint8Array;
readonly issuer: PeerId;
readonly issuerSignature: Uint8Array;
readonly expiresAt?: number;
}

View File

@@ -0,0 +1,53 @@
# depth
## Purpose
Folder-depth check for `DriveTemplate.rootFolder` (DTP-08). Counts from the root (depth 0) and rejects the first folder reaching depth `MAX_FOLDER_DEPTH` (32) with `DRIVE.TEMPLATE_TOO_DEEP`. The cap is exclusive — depth 32 is allowed; depth 33 triggers rejection. Files do not count toward the depth — only `FolderTemplate` chains.
## Features
- `assertFolderDepth(folder)` — single-call walker.
- `MAX_FOLDER_DEPTH` constant pinned at 32 so dashboards can reference the same source-of-truth as the spec.
- `path` accumulator threaded through the recursion so `DriveTemplateError.context.path` names the offending node — handy when the hydration UI wants to surface a "this drive's folder structure is too deep" error pointing at the path that broke.
## Usage: Basic Example
```ts
import { assertFolderDepth } from "@suaveplan/drive-template";
assertFolderDepth(template.rootFolder);
// throws DriveTemplateError(DRIVE.TEMPLATE_TOO_DEEP) past depth 32
```
## Usage: Advanced Example
```ts
import {
assertFolderDepth,
DTP_CODES,
DriveTemplateError,
MAX_FOLDER_DEPTH,
} from "@suaveplan/drive-template";
function describeDepth(folder: import("@suaveplan/drive-template").FolderTemplate) {
try {
assertFolderDepth(folder);
return `valid (≤ ${MAX_FOLDER_DEPTH})`;
} catch (err) {
if (err instanceof DriveTemplateError && err.code === DTP_CODES.TEMPLATE_TOO_DEEP) {
const ctx = err.context as { path?: string };
return `too deep at ${ctx.path ?? "<root>"}`;
}
throw err;
}
}
```
## API Reference
- `assertFolderDepth(folder: FolderTemplate): void` — throws `DriveTemplateError(DRIVE.TEMPLATE_TOO_DEEP)` past depth 32.
- `MAX_FOLDER_DEPTH: 32`.
## Implementation Notes
The walker treats files as leaves — it returns immediately when `"children" in node` is false. This keeps the cap a structural folder limit rather than a "max number of files in any path" limit, which is what consumers want when they're trying to limit display-tree complexity. The path accumulator is built with the spread operator on each recursive call rather than mutated; the depth-32 limit means at most 32 levels of array allocation per validation, which is negligible.

View File

@@ -0,0 +1,37 @@
/**
* Folder-depth check for `DriveTemplate.rootFolder` (DTP-08).
*
* Counts from the root (depth 0) and rejects the first folder reaching
* depth `MAX_FOLDER_DEPTH` with `DRIVE.TEMPLATE_TOO_DEEP`. The cap is
* exclusive — depth `MAX_FOLDER_DEPTH - 1` is allowed; depth
* `MAX_FOLDER_DEPTH` triggers rejection. Files do not count toward
* the depth — only `FolderTemplate` chains.
*/
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { FileTemplate, FolderTemplate } from "../types/template.types.js";
/** Maximum recursive folder depth (inclusive). */
export const MAX_FOLDER_DEPTH = 32 as const;
export function assertFolderDepth(folder: FolderTemplate): void {
walk(folder, 0, []);
}
function walk(
node: FolderTemplate | FileTemplate,
depth: number,
path: string[]
): void {
if (!("children" in node)) return;
if (depth > MAX_FOLDER_DEPTH) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_TOO_DEEP,
`folder depth ${depth} exceeds the maximum of ${MAX_FOLDER_DEPTH}`,
{ context: { path: path.join("/") } }
);
}
for (const child of node.children) {
walk(child, depth + 1, [...path, node.name]);
}
}

View File

@@ -0,0 +1,54 @@
# file
## Purpose
Per-file consistency invariants for every `FileTemplate` (DTP-09). Files that fail any invariant are rejected with `DRIVE.TEMPLATE_MALFORMED` and a `context` object naming the offending file path. The walker recurses through the folder tree so the failure surfaces with the full path from the root.
## Features
- `assertFolderFileInvariants(folder)` — recurses, checking every nested file.
- `assertFile(file, path)` — single-file check, used directly in the hydration scheduler when files arrive piecemeal.
- `MIN_CHUNK_SIZE_EXCLUSIVE` constant (164) — the chunk-format header overhead requires at least one payload byte.
- Five gates: `chunkSize > 164`, `totalChunks * chunkSize >= size`, `manifestKey` non-empty, `checksum` matches `'blake3:<hex>'` or `'sha256:<hex>'`, `manifestSig` non-empty.
## Usage: Basic Example
```ts
import { assertFile } from "@suaveplan/drive-template";
assertFile(file, "/photos/wedding.jpg");
// throws DriveTemplateError(DRIVE.TEMPLATE_MALFORMED) on any invariant violation
```
## Usage: Advanced Example
```ts
import {
assertFolderFileInvariants,
DTP_CODES,
DriveTemplateError,
} from "@suaveplan/drive-template";
function reportFileFailures(folder) {
try {
assertFolderFileInvariants(folder);
return null;
} catch (err) {
if (err instanceof DriveTemplateError && err.code === DTP_CODES.TEMPLATE_MALFORMED) {
const ctx = err.context as { file: string; issue: string };
return { file: ctx.file, issue: ctx.issue };
}
throw err;
}
}
```
## API Reference
- `assertFolderFileInvariants(folder: FolderTemplate): void`.
- `assertFile(file: FileTemplate, path: string): void`.
- `MIN_CHUNK_SIZE_EXCLUSIVE: 164` — exclusive lower bound for `chunkSize`.
## Implementation Notes
The 164-byte chunk-format overhead matches `@suaveplan/drive-plugin-chunks` `HEADER_SIZE`. Keeping `chunkSize` strictly greater than the header ensures every chunk carries at least one byte of payload — otherwise the manifest would describe a file that consists entirely of headers with no bytes. The checksum regex accepts both `blake3:<hex>` and `sha256:<hex>` forms so consumers running on platforms without BLAKE3 native bindings can still ship templates checksummed via SHA-256. Folder traversal accumulates the path through the spread operator, which keeps the failure context's `file` field human-readable for any UI surfacing the error.

View File

@@ -0,0 +1,71 @@
/**
* Per-file consistency invariants for every `FileTemplate` (DTP-09).
*
* - `chunkSize > 164` — the chunk header overhead requires positive
* payload room.
* - `totalChunks * chunkSize >= size` — chunks must cover the file.
* - `manifestKey` non-empty — the manifest IS the source of truth.
* - `checksum` matches `'blake3:<hex>'` or `'sha256:<hex>'`.
* - `manifestSig` non-empty — the issuer signed the manifest.
*
* Failures throw `DriveTemplateError(DRIVE.TEMPLATE_MALFORMED)` with a
* `context` object naming the offending file and the specific issue.
*/
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { FileTemplate, FolderTemplate } from "../types/template.types.js";
/** Minimum legal chunk size — header (164) plus at least one payload byte. */
export const MIN_CHUNK_SIZE_EXCLUSIVE = 164 as const;
const CHECKSUM_PATTERN = /^(?:blake3|sha256):[0-9a-f]+$/i;
export function assertFolderFileInvariants(folder: FolderTemplate): void {
walk(folder, []);
}
function walk(node: FolderTemplate | FileTemplate, path: string[]): void {
if ("children" in node) {
for (const child of node.children) {
walk(child, [...path, node.name]);
}
return;
}
const here = [...path, node.name].join("/");
assertFile(node, here);
}
export function assertFile(file: FileTemplate, path: string): void {
if (file.chunkSize <= MIN_CHUNK_SIZE_EXCLUSIVE) {
throw fail(
path,
`chunkSize ${file.chunkSize} <= ${MIN_CHUNK_SIZE_EXCLUSIVE}`
);
}
if (file.totalChunks * file.chunkSize < file.size) {
throw fail(
path,
`totalChunks (${file.totalChunks}) * chunkSize (${file.chunkSize}) = ${file.totalChunks * file.chunkSize} < size ${file.size}`
);
}
if (file.manifestKey.length === 0) {
throw fail(path, "manifestKey must be non-empty");
}
if (!CHECKSUM_PATTERN.test(file.checksum)) {
throw fail(
path,
`checksum must match 'blake3:<hex>' or 'sha256:<hex>'; got ${file.checksum}`
);
}
if (file.manifestSig.length === 0) {
throw fail(path, "manifestSig must be non-empty");
}
}
function fail(path: string, issue: string): DriveTemplateError {
return new DriveTemplateError(
DTP_CODES.TEMPLATE_MALFORMED,
`file ${path}: ${issue}`,
{ context: { file: path, issue } }
);
}

View File

@@ -0,0 +1,18 @@
export {
assertFolderDepth,
MAX_FOLDER_DEPTH,
} from "./depth.js";
export {
assertFile,
assertFolderFileInvariants,
MIN_CHUNK_SIZE_EXCLUSIVE,
} from "./file.js";
export {
ISSUANCE_WINDOW_MS,
type ValidationContext,
validateTemplate,
} from "./order.js";
export {
TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY,
TemplateNonceLru,
} from "./replay.js";

View File

@@ -0,0 +1,74 @@
# order
## Purpose
Implements the 8-step validation pipeline that `Drive.hydrateFrom(template)` runs against every incoming `DriveTemplate` (DTP-05). Failure at any step aborts before the next; the error code is pinned to the spec so monitoring dashboards can attach SLOs per stage.
## Features
- `validateTemplate(template, ctx)` — runs the full 8-step pipeline.
- Step order is fixed: schema-version, expiry, issuance window, replay LRU, trusted issuer, server signature, folder depth, per-file invariants.
- Replay LRU commit happens AFTER the signature check so a tampered template doesn't pollute the cache.
- `ValidationContext` carries the injectable `Clock` and the per-client `TemplateNonceLru` so tests can step time deterministically.
## Usage: Basic Example
```ts
import {
TemplateNonceLru,
validateTemplate,
} from "@suaveplan/drive-template";
const lru = new TemplateNonceLru();
validateTemplate(template, {
clock: () => Date.now(),
nonceLru: lru,
});
```
## Usage: Advanced Example
```ts
import {
DTP_CODES,
DriveTemplateError,
TemplateNonceLru,
validateTemplate,
} from "@suaveplan/drive-template";
const lru = new TemplateNonceLru();
async function hydrateFrom(template) {
try {
validateTemplate(template, {
clock: () => Date.now(),
nonceLru: lru,
});
} catch (err) {
if (err instanceof DriveTemplateError) {
switch (err.code) {
case DTP_CODES.TEMPLATE_EXPIRED:
case DTP_CODES.TEMPLATE_STALE:
return refreshTemplate();
case DTP_CODES.TEMPLATE_SIG_INVALID:
case DTP_CODES.TEMPLATE_UNTRUSTED_ISSUER:
return alertOps(err);
default:
throw err;
}
}
throw err;
}
return realisTree(template);
}
```
## API Reference
- `validateTemplate(template: DriveTemplate, ctx: ValidationContext): void`.
- `interface ValidationContext` — `{ clock: Clock; nonceLru: TemplateNonceLru }`.
- `ISSUANCE_WINDOW_MS: number` — fixed 24-hour issuance window pinned by DTP-07.
## Implementation Notes
The `trustedKeyAuthorities` allowlist is treated as advisory when empty — an empty array means "no allowlist configured" rather than "deny all". This matches the spec's `globalPolicy.trustedKeyAuthorities` being `optional`, but it surfaces as a hard precondition in the validator so the failure mode is well-defined. The replay-LRU `remember` call is the last side effect in the pipeline so an exception thrown in any prior step rolls back cleanly. Step ordering is structurally important: cheap synchronous checks come first so a malformed template never reaches the cryptographic verification path.

View File

@@ -0,0 +1,88 @@
/**
* 8-step validation pipeline for `DriveTemplate` (DTP-05).
*
* Failure at any step aborts before the next; the codes are pinned to
* the spec so monitoring dashboards can attach SLOs per stage:
*
* 1. schemaVersion === 1 → DRIVE.TEMPLATE_VERSION_UNSUPPORTED
* 2. now < expiresAt → DRIVE.TEMPLATE_EXPIRED
* 3. now - issuedAt < ISSUANCE_WINDOW → DRIVE.TEMPLATE_STALE
* 4. (driveId, nonce) ∉ replay LRU → DRIVE.TEMPLATE_REPLAY
* 5. issuer ∈ trustedKeyAuthorities → DRIVE.TEMPLATE_UNTRUSTED_ISSUER
* 6. Ed25519-verify(serverSignature) → DRIVE.TEMPLATE_SIG_INVALID
* 7. folder depth ≤ MAX_FOLDER_DEPTH → DRIVE.TEMPLATE_TOO_DEEP
* 8. per-file invariants → DRIVE.TEMPLATE_MALFORMED
*/
import type { Clock } from "@suaveplan/types";
import { verifyTemplateSignature } from "../codec/verify.js";
import { DriveTemplateError, DTP_CODES } from "../error-codes.js";
import type { DriveTemplate } from "../types/template.types.js";
import { assertFolderDepth } from "./depth.js";
import { assertFolderFileInvariants } from "./file.js";
import type { TemplateNonceLru } from "./replay.js";
/** 24-hour issuance window pinned by DTP-07. */
export const ISSUANCE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Validation context that survives across `validateTemplate` calls. */
export interface ValidationContext {
readonly clock: Clock;
readonly nonceLru: TemplateNonceLru;
}
/** Run the full 8-step validation pipeline. */
export function validateTemplate(
template: DriveTemplate,
ctx: ValidationContext
): void {
// 1. Schema version
if (template.schemaVersion !== 1) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_VERSION_UNSUPPORTED,
`template.schemaVersion must be 1; got ${(template as { schemaVersion: number }).schemaVersion}`
);
}
const now = ctx.clock();
// 2. Expiry
if (now >= template.expiresAt) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_EXPIRED,
`template expired at ${template.expiresAt} (now: ${now})`
);
}
// 3. Issuance window
if (now - template.issuedAt >= ISSUANCE_WINDOW_MS) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_STALE,
`template issuedAt ${template.issuedAt} is older than ${ISSUANCE_WINDOW_MS}ms`
);
}
// 4. Replay
if (ctx.nonceLru.has(template.driveId, template.nonce)) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_REPLAY,
"replay rejected: (driveId, nonce) already seen",
{ context: { driveId: template.driveId } }
);
}
// 5. Trusted issuer
const trusted = template.policy.trustedKeyAuthorities;
if (trusted !== undefined && trusted.length > 0) {
if (!trusted.includes(template.issuer)) {
throw new DriveTemplateError(
DTP_CODES.TEMPLATE_UNTRUSTED_ISSUER,
`issuer ${template.issuer} is not in trustedKeyAuthorities`
);
}
}
// 6. Server signature
verifyTemplateSignature(template);
// 7. Folder depth
assertFolderDepth(template.rootFolder);
// 8. Per-file consistency
assertFolderFileInvariants(template.rootFolder);
// Only after all checks pass do we commit to the LRU.
ctx.nonceLru.remember(template.driveId, template.nonce);
}

View File

@@ -0,0 +1,51 @@
# replay
## Purpose
Per-client LRU of `(driveId, nonce)` tuples used to reject replayed templates (DTP-06). The cache holds at most `LRU_CAPACITY` entries (default 1024); inserting a new entry that exceeds the cap evicts the least-recently-used one. The LRU is kept in-memory only; the spec calls out that `Drive.initialize()` rebuilds it from the FileLocationIndex.
## Features
- `TemplateNonceLru` — bounded LRU keyed on `(driveId, nonce-hex)`.
- `has(driveId, nonce)` — query without mutation.
- `remember(driveId, nonce)` — insert; auto-evicts oldest past the cap.
- `clear()` / `size` / `capacity` — utility surface for tests + persistence hooks.
- `TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY` — pinned at 1024 per DTP-06.
## Usage: Basic Example
```ts
import { TemplateNonceLru } from "@suaveplan/drive-template";
const lru = new TemplateNonceLru();
lru.remember("drive-1", new Uint8Array(16).fill(7));
console.log(lru.has("drive-1", new Uint8Array(16).fill(7))); // true
```
## Usage: Advanced Example
```ts
import {
TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY,
TemplateNonceLru,
} from "@suaveplan/drive-template";
const lru = new TemplateNonceLru(TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY);
async function rebuildFromFileLocationIndex(seen: Iterable<{ driveId: string; nonce: Uint8Array }>) {
lru.clear();
for (const entry of seen) {
lru.remember(entry.driveId, entry.nonce);
}
return lru.size;
}
```
## API Reference
- `class TemplateNonceLru` — `has`, `remember`, `clear`, `size`, `capacity`.
- `TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY: 1024`.
## Implementation Notes
The implementation uses an insertion-ordered `Map` and re-inserts duplicates on `remember` to refresh recency. The keys are `<driveId>:<nonce-hex>` strings rather than tuple objects so `Map`'s `===` equality works without needing a custom key class. The capacity check fires AFTER the insert so the eviction policy is "drop the actual oldest", not "drop the entry we're about to overwrite" — this matters when a template re-uses the same `(driveId, nonce)` shortly after another distribution. Persistence support lives in `Drive.initialize()` rather than this module: the LRU rebuilds from the FileLocationIndex on startup so a process restart does not open a replay window.

View File

@@ -0,0 +1,67 @@
/**
* Per-client LRU of `(driveId, nonce)` tuples used to reject replayed
* templates (DTP-06). The cache holds at most `LRU_CAPACITY` entries
* (default 1024); inserting a new entry evicts the least-recently-used
* one. The LRU is intentionally kept in-memory only — the spec calls
* out that `Drive.initialize()` rebuilds it from the FileLocationIndex.
*/
const LRU_CAPACITY_DEFAULT = 1024 as const;
function key(driveId: string, nonce: Uint8Array): string {
let hex = "";
for (let i = 0; i < nonce.length; i++) {
const b = nonce[i] as number;
hex += b.toString(16).padStart(2, "0");
}
return `${driveId}:${hex}`;
}
export class TemplateNonceLru {
readonly #order = new Map<string, number>();
readonly #capacity: number;
#counter = 0;
public constructor(capacity = LRU_CAPACITY_DEFAULT) {
if (capacity <= 0 || !Number.isInteger(capacity)) {
throw new Error(
`TemplateNonceLru capacity must be a positive integer; got ${capacity}`
);
}
this.#capacity = capacity;
}
public has(driveId: string, nonce: Uint8Array): boolean {
return this.#order.has(key(driveId, nonce));
}
/** Insert the tuple; evicts the LRU entry if the cap is exceeded. */
public remember(driveId: string, nonce: Uint8Array): void {
const k = key(driveId, nonce);
this.#counter++;
this.#order.delete(k); // re-insert at end if already present
this.#order.set(k, this.#counter);
if (this.#order.size > this.#capacity) {
// The Map is non-empty by construction (we just .set'd above and
// exceeded capacity ≥ 1), so .keys().next().value is always
// defined.
const oldest = this.#order.keys().next().value as string;
this.#order.delete(oldest);
}
}
public clear(): void {
this.#order.clear();
this.#counter = 0;
}
public get size(): number {
return this.#order.size;
}
public get capacity(): number {
return this.#capacity;
}
}
export const TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY = LRU_CAPACITY_DEFAULT;

View File

@@ -0,0 +1,71 @@
/**
* Shared helpers for the drive-template test suite. Builds canonical
* sample templates, signs them, and exposes injectable clocks +
* deterministic Ed25519 keypairs so individual tests stay focused on
* the requirement they verify.
*/
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { toHex } from "@suaveplan/crypto-utils";
import { signTemplate } from "../src/codec/sign.js";
import type {
DriveTemplate,
FolderTemplate,
PerFilePolicy,
} from "../src/types/template.types.js";
export interface SampleKeys {
readonly secret: Uint8Array;
readonly publicHex: string;
}
export function makeKeys(): SampleKeys {
const secret = randomPrivateKey();
const publicHex = toHex(getPublicKey(secret));
return { secret, publicHex };
}
export function makeNonce(value = 7): Uint8Array {
return new Uint8Array(16).fill(value);
}
export const DEFAULT_POLICY: PerFilePolicy = {
autoDownload: false,
meshAllowed: true,
cdnAllowed: true,
retention: "lru",
};
export function emptyRoot(): FolderTemplate {
return { name: "/", children: [] };
}
export interface BuildOptions {
readonly issuer: string;
readonly nonce?: Uint8Array;
readonly issuedAt?: number;
readonly expiresAt?: number;
readonly rootFolder?: FolderTemplate;
}
export function buildTemplate(
options: BuildOptions
): Omit<DriveTemplate, "serverSignature"> {
return {
schemaVersion: 1,
driveId: "drive-1",
rootFolder: options.rootFolder ?? emptyRoot(),
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: options.issuer,
issuedAt: options.issuedAt ?? Date.now() - 1_000,
expiresAt: options.expiresAt ?? Date.now() + 24 * 60 * 60 * 1000,
nonce: options.nonce ?? makeNonce(),
};
}
export function signed(options: BuildOptions, keys: SampleKeys): DriveTemplate {
return signTemplate(buildTemplate(options), keys.secret);
}

View File

@@ -0,0 +1,305 @@
/**
* Branch-coverage filler for canonical.ts encryption / retention /
* optional-field paths and other defence-in-depth code that the
* spec-tied tests don't exercise.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { canonicalSerialize } from "../src/codec/canonical.js";
import type {
DriveTemplate,
FileTemplate,
FolderTemplate,
} from "../src/index.js";
import { TemplateNonceLru, validateTemplate } from "../src/index.js";
import {
DEFAULT_POLICY,
emptyRoot,
makeKeys,
makeNonce,
signed,
} from "./_helpers.js";
const baseFile: FileTemplate = {
name: "x.bin",
fileLocId: "loc-1",
fileId: "f".repeat(64),
size: 100,
totalChunks: 1,
chunkSize: 1024,
checksum: "sha256:abcdef",
flags: DEFAULT_POLICY,
manifestKey: "manifest:f",
manifestSig: new Uint8Array(64).fill(1),
};
describe("canonicalSerialize encryption branches", () => {
it("file with kind:inline encryption serializes", { timeout: 5000 }, () => {
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: {
name: "/",
children: [
{
...baseFile,
encryption: {
algorithm: "xchacha20-poly1305",
keyDistribution: {
kind: "inline",
wrappedKey: new Uint8Array(80).fill(0xab),
},
},
},
],
},
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
});
it("file with kind:retroactive encryption serializes", {
timeout: 5000,
}, () => {
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: {
name: "/",
children: [
{
...baseFile,
encryption: {
algorithm: "xchacha20-poly1305",
keyDistribution: {
kind: "retroactive",
keyAuthority: "auth-1",
},
},
},
],
},
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
});
it("retention 'permanent' / 'lru' / { maxAgeMs } / undefined serialize", {
timeout: 5000,
}, () => {
for (const retention of [
undefined,
"permanent" as const,
"lru" as const,
{ maxAgeMs: 1000 },
]) {
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: {
defaultPolicy: { ...DEFAULT_POLICY, retention },
},
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: emptyRoot(),
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
}
});
it("GlobalPolicy with maxLocalCacheBytes / maxConcurrentFetches set", {
timeout: 5000,
}, () => {
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: {
defaultPolicy: DEFAULT_POLICY,
maxLocalCacheBytes: 1_000_000,
maxConcurrentFetches: 4,
trustedKeyAuthorities: ["a", "b"],
},
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: emptyRoot(),
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
});
it("CdnFallbackConfig with optional uploadEndpoint serialises", {
timeout: 5000,
}, () => {
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: emptyRoot(),
cdnFallback: {
baseUrl: "https://example.com",
pathScheme: "reverse-id",
levels: 3,
padding: 9,
uploadEndpoint: "https://upload.example.com",
},
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
});
it("CdnFallbackConfig WITHOUT uploadEndpoint covers optional false branch", {
timeout: 5000,
}, () => {
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: emptyRoot(),
cdnFallback: {
baseUrl: "https://example.com",
pathScheme: "reverse-id",
levels: 3,
padding: 9,
},
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
});
it("nested folder serialises with TAG_FOLDER recursion", {
timeout: 5000,
}, () => {
const inner: FolderTemplate = { name: "deep", children: [] };
const outer: FolderTemplate = { name: "/", children: [inner] };
const tpl: Omit<DriveTemplate, "serverSignature"> = {
schemaVersion: 1,
driveId: "d",
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
rootFolder: outer,
};
expect(canonicalSerialize(tpl).length).toBeGreaterThan(0);
});
});
describe("schema invocation coverage", () => {
it("DriveTemplateSchema rejects nonce of wrong length", {
timeout: 5000,
}, async () => {
const { DriveTemplateSchema } = await import("../src/index.js");
const r = DriveTemplateSchema.safeParse({
schemaVersion: 1,
driveId: "d",
rootFolder: { name: "/", children: [] },
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: new Uint8Array(8), // wrong length
serverSignature: new Uint8Array(64),
});
expect(r.success).toBe(false);
});
it("DriveTemplateSchema accepts a well-formed payload", {
timeout: 5000,
}, async () => {
const { DriveTemplateSchema } = await import("../src/index.js");
const r = DriveTemplateSchema.safeParse({
schemaVersion: 1,
driveId: "d",
rootFolder: { name: "/", children: [] },
policy: { defaultPolicy: DEFAULT_POLICY },
issuer: "00".repeat(32),
issuedAt: 1,
expiresAt: 1_000,
nonce: makeNonce(),
serverSignature: new Uint8Array(64),
});
expect(r.success).toBe(true);
});
it("FolderTemplateSchema parses recursive folder/file mix", {
timeout: 5000,
}, async () => {
const { FolderTemplateSchema } = await import("../src/index.js");
const r = FolderTemplateSchema.safeParse({
name: "/",
children: [
{
name: "subfolder",
children: [],
},
{
name: "file.bin",
fileLocId: "loc",
fileId: "fid",
size: 1,
totalChunks: 1,
chunkSize: 1024,
checksum: "blake3:abcd",
flags: DEFAULT_POLICY,
manifestKey: "k",
manifestSig: new Uint8Array(64),
},
],
});
expect(r.success).toBe(true);
});
});
describe("depth check accepts a folder containing a file", () => {
it("walk descends into folder, returns from file branch", {
timeout: 5000,
}, async () => {
const { assertFolderDepth } = await import("../src/index.js");
const tree: FolderTemplate = {
name: "/",
children: [baseFile],
};
expect(() => assertFolderDepth(tree)).not.toThrow();
});
});
describe("validate.replay LRU empty-on-evict edge", () => {
it("LRU eviction on size==capacity is a no-op pre-add", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const ctx = {
clock: () => Date.now(),
nonceLru: new TemplateNonceLru(2),
};
const tpl1 = signed(
{ issuer: keys.publicHex, nonce: makeNonce(1) },
keys
);
const tpl2 = signed(
{ issuer: keys.publicHex, nonce: makeNonce(2) },
keys
);
validateTemplate(tpl1, ctx);
validateTemplate(tpl2, ctx);
expect(ctx.nonceLru.size).toBe(2);
});
});

View File

@@ -0,0 +1,117 @@
/**
* DTP-17 — CDN reversed-ID URL convention.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { CdnFallbackConfig } from "../src/index.js";
import {
buildCdnChunkUrl,
DEFAULT_CDN_LEVELS,
DEFAULT_CDN_PADDING,
} from "../src/index.js";
const BASE: CdnFallbackConfig = {
baseUrl: "https://chunks.example.com",
pathScheme: "reverse-id",
levels: DEFAULT_CDN_LEVELS,
padding: DEFAULT_CDN_PADDING,
};
describe("DTP-17 — reverse-id URL convention", () => {
it("URL builder produces correct path for various levels and padding values", {
timeout: 5000,
}, () => {
const url = buildCdnChunkUrl(BASE, "abcdef1234567890", 47);
expect(url).toBe(
"https://chunks.example.com/0/9/8/abcdef1234567890_000000047.chunk"
);
});
it("levels override changes the directory depth", { timeout: 5000 }, () => {
const url = buildCdnChunkUrl(
{ ...BASE, levels: 1 },
"abcdef1234567890",
0
);
expect(url).toBe(
"https://chunks.example.com/0/abcdef1234567890_000000000.chunk"
);
});
it("levels=0 produces no directory prefix", { timeout: 5000 }, () => {
const url = buildCdnChunkUrl({ ...BASE, levels: 0 }, "abcdef", 0);
expect(url).toBe("https://chunks.example.com/abcdef_000000000.chunk");
});
it("padding override affects chunk-id zero-padding", {
timeout: 5000,
}, () => {
const url = buildCdnChunkUrl(
{ ...BASE, padding: 4 },
"abcdef1234567890",
12
);
expect(url).toBe(
"https://chunks.example.com/0/9/8/abcdef1234567890_0012.chunk"
);
});
it("trailing slash on baseUrl is stripped", { timeout: 5000 }, () => {
const url = buildCdnChunkUrl(
{ ...BASE, baseUrl: "https://chunks.example.com/" },
"abcdef1234567890",
1
);
expect(url.startsWith("https://chunks.example.com/0/9/8/")).toBe(true);
});
it("identical inputs produce byte-identical URLs", {
timeout: 5000,
}, () => {
const a = buildCdnChunkUrl(BASE, "abcdef", 99);
const b = buildCdnChunkUrl(BASE, "abcdef", 99);
expect(a).toBe(b);
});
it("rejects empty fileId", { timeout: 5000 }, () => {
expect(() => buildCdnChunkUrl(BASE, "", 0)).toThrow();
});
it("rejects negative chunkId", { timeout: 5000 }, () => {
expect(() => buildCdnChunkUrl(BASE, "abcdef", -1)).toThrow();
});
it("rejects non-integer chunkId", { timeout: 5000 }, () => {
expect(() => buildCdnChunkUrl(BASE, "abcdef", 1.5)).toThrow();
});
it("rejects negative levels", { timeout: 5000 }, () => {
expect(() =>
buildCdnChunkUrl({ ...BASE, levels: -1 }, "abcdef", 0)
).toThrow();
});
it("rejects non-integer levels", { timeout: 5000 }, () => {
expect(() =>
buildCdnChunkUrl({ ...BASE, levels: 1.5 }, "abcdef", 0)
).toThrow();
});
it("rejects levels > fileId length", { timeout: 5000 }, () => {
expect(() =>
buildCdnChunkUrl({ ...BASE, levels: 10 }, "abc", 0)
).toThrow();
});
it("rejects negative padding", { timeout: 5000 }, () => {
expect(() =>
buildCdnChunkUrl({ ...BASE, padding: -1 }, "abcdef", 0)
).toThrow();
});
it("rejects non-integer padding", { timeout: 5000 }, () => {
expect(() =>
buildCdnChunkUrl({ ...BASE, padding: 1.5 }, "abcdef", 0)
).toThrow();
});
});

View File

@@ -0,0 +1,170 @@
/**
* DTP-19 — CdnUploadToken issuance and verification.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { CdnUploadToken } from "../src/index.js";
import {
canonicalSerializeUploadToken,
type DriveTemplateError,
DTP_CODES,
signUploadToken,
UPLOAD_TOKEN_TTL_DEFAULT_MS,
UPLOAD_TOKEN_TTL_MAX_MS,
verifyUploadToken,
} from "../src/index.js";
import { makeKeys } from "./_helpers.js";
const NOW = 2_000_000_000;
function baseToken(): Omit<CdnUploadToken, "serverSignature" | "expiresAt"> {
return {
fileId: "f".repeat(64),
chunkId: 12,
chunkHash: "blake3:abcdef",
uploaderPubkey: "pubkey-hex",
};
}
describe("DTP-19 — CdnUploadToken", () => {
it("default TTL pinned at 60s; max at 5m", { timeout: 5000 }, () => {
expect(UPLOAD_TOKEN_TTL_DEFAULT_MS).toBe(60_000);
expect(UPLOAD_TOKEN_TTL_MAX_MS).toBe(300_000);
});
it("valid token accepted", { timeout: 5000 }, () => {
const keys = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: NOW + 30_000 },
keys.secret,
{ clock: () => NOW }
);
expect(verifyUploadToken(tok, keys.publicHex, () => NOW + 1_000)).toBe(
true
);
});
it("expired token rejected", { timeout: 5000 }, () => {
const keys = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: NOW + 30_000 },
keys.secret,
{ clock: () => NOW }
);
try {
verifyUploadToken(tok, keys.publicHex, () => NOW + 100_000);
throw new Error("expected expired");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.CDN_BAD_SIGNATURE
);
}
});
it("tampered token rejected", { timeout: 5000 }, () => {
const keys = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: NOW + 30_000 },
keys.secret,
{ clock: () => NOW }
);
const tampered = { ...tok, chunkId: tok.chunkId + 1 };
try {
verifyUploadToken(tampered, keys.publicHex, () => NOW);
throw new Error("expected sig failure");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.CDN_BAD_SIGNATURE
);
}
});
it("token signed by wrong key rejected", { timeout: 5000 }, () => {
const k1 = makeKeys();
const k2 = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: NOW + 30_000 },
k1.secret,
{ clock: () => NOW }
);
try {
verifyUploadToken(tok, k2.publicHex, () => NOW);
throw new Error("expected sig failure");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.CDN_BAD_SIGNATURE
);
}
});
it("invalid server pubkey hex rejected", { timeout: 5000 }, () => {
const keys = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: NOW + 30_000 },
keys.secret,
{ clock: () => NOW }
);
try {
verifyUploadToken(tok, "not-hex", () => NOW);
throw new Error("expected pubkey rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.CDN_BAD_SIGNATURE
);
}
});
it("ttlMs out of range rejected at sign", { timeout: 5000 }, () => {
const keys = makeKeys();
expect(() =>
signUploadToken({ ...baseToken(), expiresAt: 0 }, keys.secret, {
clock: () => NOW,
ttlMs: 0,
})
).toThrow();
expect(() =>
signUploadToken({ ...baseToken(), expiresAt: 0 }, keys.secret, {
clock: () => NOW,
ttlMs: UPLOAD_TOKEN_TTL_MAX_MS + 1,
})
).toThrow();
});
it("canonicalSerializeUploadToken is deterministic", {
timeout: 5000,
}, () => {
const tok = { ...baseToken(), expiresAt: NOW + 30_000 };
const a = canonicalSerializeUploadToken(tok);
const b = canonicalSerializeUploadToken(tok);
expect(a).toEqual(b);
});
it("default options use Date.now() as clock", { timeout: 5000 }, () => {
const keys = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: 0 },
keys.secret
);
expect(tok.expiresAt).toBeGreaterThan(0);
});
it("malformed signature buffer triggers verify catch arm", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tok = signUploadToken(
{ ...baseToken(), expiresAt: NOW + 30_000 },
keys.secret,
{ clock: () => NOW }
);
const broken = { ...tok, serverSignature: new Uint8Array(8) };
try {
verifyUploadToken(broken, keys.publicHex, () => NOW);
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.CDN_BAD_SIGNATURE
);
}
});
});

View File

@@ -0,0 +1,48 @@
/**
* DTP error-codes spec conformance — registry frozen, every code
* usable to construct a `DriveTemplateError`, namespaced under `DTP.`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DriveTemplateError, DTP_CODES } from "../src/index.js";
describe("DTP_CODES is frozen", () => {
it("DTP_CODES is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(DTP_CODES)).toBe(true);
});
it("every value namespaced under DTP.", { timeout: 5000 }, () => {
for (const v of Object.values(DTP_CODES)) {
expect(v).toMatch(/^DTP\./);
}
});
it("every code constructs a DriveTemplateError that preserves it", {
timeout: 5000,
}, () => {
for (const code of Object.values(DTP_CODES)) {
const e = new DriveTemplateError(code, "ok");
expect(e.code).toBe(code);
}
});
it("registry contains the eight pipeline + two operational codes", {
timeout: 5000,
}, () => {
const expected = [
"TEMPLATE_VERSION_UNSUPPORTED",
"TEMPLATE_EXPIRED",
"TEMPLATE_STALE",
"TEMPLATE_REPLAY",
"TEMPLATE_UNTRUSTED_ISSUER",
"TEMPLATE_SIG_INVALID",
"TEMPLATE_TOO_DEEP",
"TEMPLATE_MALFORMED",
"CDN_BAD_SIGNATURE",
"KEY_GRANT_REJECTED",
] as const;
for (const k of expected) {
expect(DTP_CODES).toHaveProperty(k);
}
});
});

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DriveTemplateError,
DTP_CODES,
type DtpErrorCode,
} from "../src/error-codes.js";
describe("@suaveplan/drive-template — DTP_CODES registry", () => {
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(DTP_CODES)).toBe(true);
});
it("every code value starts with the DTP. namespace", {
timeout: 5000,
}, () => {
for (const value of Object.values(DTP_CODES)) {
expect(value).toMatch(/^DTP\./);
}
});
it("includes every documented rejection path", { timeout: 5000 }, () => {
const expected = [
"TEMPLATE_VERSION_UNSUPPORTED",
"TEMPLATE_EXPIRED",
"TEMPLATE_STALE",
"TEMPLATE_REPLAY",
"TEMPLATE_UNTRUSTED_ISSUER",
"TEMPLATE_SIG_INVALID",
"TEMPLATE_TOO_DEEP",
"TEMPLATE_MALFORMED",
"CDN_BAD_SIGNATURE",
"KEY_GRANT_REJECTED",
] as const;
for (const key of expected) {
expect(DTP_CODES).toHaveProperty(key);
}
});
});
describe("@suaveplan/drive-template — DriveTemplateError class", () => {
it("preserves the code", { timeout: 5000 }, () => {
const err = new DriveTemplateError(
DTP_CODES.TEMPLATE_EXPIRED,
"test message"
);
expect(err.code).toBe(DTP_CODES.TEMPLATE_EXPIRED);
expect(err.message).toBe("test message");
});
it("threads cause and context through SuaveplanError options", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new DriveTemplateError(
DTP_CODES.TEMPLATE_SIG_INVALID,
"bad sig",
{
cause,
context: { issuer: "alice" },
}
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ issuer: "alice" });
});
it("permits all codes via DtpErrorCode union", { timeout: 5000 }, () => {
const codes: DtpErrorCode[] = Object.values(DTP_CODES);
expect(codes.length).toBeGreaterThanOrEqual(10);
for (const code of codes) {
const err = new DriveTemplateError(code, "ok");
expect(err.code).toBe(code);
}
});
});

View File

@@ -0,0 +1,228 @@
/**
* DTP-21 — KeyGrantEnvelope validator (issuer / recipient / signature
* / expiry checks in spec-mandated order).
*/
import { sign } from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { KeyGrantPayload } from "../src/index.js";
import {
canonicalSerializeKeyGrant,
type DriveTemplateError,
DTP_CODES,
validateKeyGrant,
} from "../src/index.js";
import { makeKeys } from "./_helpers.js";
const NOW = 2_000_000_000;
function makeGrant(
issuerHex: string,
recipientHex: string,
issuerSecret: Uint8Array,
overrides: Partial<KeyGrantPayload> = {}
): KeyGrantPayload {
const base: Omit<KeyGrantPayload, "issuerSignature"> = {
fileId: "f".repeat(64),
recipient: recipientHex,
wrappedKey: new Uint8Array(48).fill(7),
issuer: issuerHex,
expiresAt: NOW + 60_000,
...overrides,
};
const canonical = canonicalSerializeKeyGrant(base);
const issuerSignature = sign(canonical, issuerSecret);
return { ...base, issuerSignature, ...overrides };
}
describe("DTP-21 — key-grant validator", () => {
it("valid grant passes", { timeout: 5000 }, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const grant = makeGrant(
issuer.publicHex,
recipient.publicHex,
issuer.secret
);
expect(
validateKeyGrant(grant, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: recipient.publicHex,
clock: () => NOW,
})
).toBe(true);
});
it("issuer mismatch rejected (DMP-21 step 1)", { timeout: 5000 }, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const grant = makeGrant(
issuer.publicHex,
recipient.publicHex,
issuer.secret
);
try {
validateKeyGrant(grant, {
expectedKeyAuthority: "different-authority",
ourPubkey: recipient.publicHex,
clock: () => NOW,
});
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
}
});
it("recipient mismatch rejected (step 2)", { timeout: 5000 }, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const someoneElse = makeKeys();
const grant = makeGrant(
issuer.publicHex,
recipient.publicHex,
issuer.secret
);
try {
validateKeyGrant(grant, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: someoneElse.publicHex,
clock: () => NOW,
});
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
}
});
it("invalid issuer hex rejected", { timeout: 5000 }, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const grant: KeyGrantPayload = {
fileId: "f",
recipient: recipient.publicHex,
wrappedKey: new Uint8Array(8),
issuer: "not-hex-zzz",
issuerSignature: new Uint8Array(64),
expiresAt: NOW + 60_000,
};
try {
validateKeyGrant(grant, {
expectedKeyAuthority: "not-hex-zzz",
ourPubkey: recipient.publicHex,
clock: () => NOW,
});
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
}
// Suppress unused-issuer lint
void issuer;
});
it("bad signature rejected (step 3)", { timeout: 5000 }, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const grant = makeGrant(
issuer.publicHex,
recipient.publicHex,
issuer.secret
);
const tampered = {
...grant,
wrappedKey: new Uint8Array(48).fill(0xff),
};
try {
validateKeyGrant(tampered, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: recipient.publicHex,
clock: () => NOW,
});
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
}
});
it("expired grant rejected (step 4)", { timeout: 5000 }, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const grant = makeGrant(
issuer.publicHex,
recipient.publicHex,
issuer.secret,
{ expiresAt: NOW - 1 }
);
try {
validateKeyGrant(grant, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: recipient.publicHex,
clock: () => NOW,
});
throw new Error("expected expired");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
}
});
it("malformed signature buffer triggers verify catch arm", {
timeout: 5000,
}, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const grant: KeyGrantPayload = {
fileId: "f",
recipient: recipient.publicHex,
wrappedKey: new Uint8Array(8),
issuer: issuer.publicHex,
issuerSignature: new Uint8Array(8),
};
try {
validateKeyGrant(grant, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: recipient.publicHex,
clock: () => NOW,
});
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
}
});
it("grant without expiresAt accepted indefinitely", {
timeout: 5000,
}, () => {
const issuer = makeKeys();
const recipient = makeKeys();
// Build without expiresAt then re-sign over the same canonical bytes.
const base: Omit<KeyGrantPayload, "issuerSignature"> = {
fileId: "f".repeat(64),
recipient: recipient.publicHex,
wrappedKey: new Uint8Array(48).fill(7),
issuer: issuer.publicHex,
};
const canonical = canonicalSerializeKeyGrant(base);
const grant: KeyGrantPayload = {
...base,
issuerSignature: sign(canonical, issuer.secret),
};
expect(
validateKeyGrant(grant, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: recipient.publicHex,
clock: () => NOW,
})
).toBe(true);
});
});

View File

@@ -0,0 +1,66 @@
/**
* DTP-05 step 2 — TEMPLATE_EXPIRED rejection.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type DriveTemplateError,
DTP_CODES,
TemplateNonceLru,
validateTemplate,
} from "../src/index.js";
import { makeKeys, signed } from "./_helpers.js";
describe("DTP-05 step 2 — expired templates", () => {
it("template past expiresAt rejected with TEMPLATE_EXPIRED", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const NOW = 2_000_000_000;
const tpl = signed(
{
issuer: keys.publicHex,
issuedAt: NOW - 1_000,
expiresAt: NOW - 1, // already expired
},
keys
);
const ctx = {
clock: () => NOW,
nonceLru: new TemplateNonceLru(),
};
try {
validateTemplate(tpl, ctx);
throw new Error("expected EXPIRED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_EXPIRED
);
}
});
it("template at exactly expiresAt rejected (>=)", { timeout: 5000 }, () => {
const keys = makeKeys();
const NOW = 2_000_000_000;
const tpl = signed(
{
issuer: keys.publicHex,
issuedAt: NOW - 1_000,
expiresAt: NOW,
},
keys
);
const ctx = {
clock: () => NOW,
nonceLru: new TemplateNonceLru(),
};
try {
validateTemplate(tpl, ctx);
throw new Error("expected EXPIRED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_EXPIRED
);
}
});
});

View File

@@ -0,0 +1,157 @@
/**
* DTP-02 / DTP-09 / DTP-26 — schema-version pinning + per-file
* consistency invariants + structured error context.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
DriveTemplate,
FileTemplate,
FolderTemplate,
} from "../src/index.js";
import {
assertFile,
assertFolderFileInvariants,
type DriveTemplateError,
DTP_CODES,
TemplateNonceLru,
validateTemplate,
} from "../src/index.js";
import { DEFAULT_POLICY, makeKeys, signed } from "./_helpers.js";
const baseFile: FileTemplate = {
name: "x.bin",
fileLocId: "loc-1",
fileId: "f".repeat(64),
size: 100,
totalChunks: 1,
chunkSize: 1024,
checksum: "blake3:abcd",
flags: DEFAULT_POLICY,
manifestKey: "manifest:f",
manifestSig: new Uint8Array(64).fill(1),
};
describe("DTP-02 — schema version pinned to 1", () => {
it("schemaVersion ≠ 1 rejected with TEMPLATE_VERSION_UNSUPPORTED", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signed({ issuer: keys.publicHex }, keys);
// Cast through unknown to bypass the literal-1 type guard.
const broken = { ...tpl, schemaVersion: 2 } as unknown as DriveTemplate;
try {
validateTemplate(broken, {
clock: () => Date.now(),
nonceLru: new TemplateNonceLru(),
});
throw new Error("expected VERSION_UNSUPPORTED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_VERSION_UNSUPPORTED
);
}
});
});
describe("DTP-09 — per-file consistency invariants", () => {
it("chunkSize 164 rejected", { timeout: 5000 }, () => {
try {
assertFile({ ...baseFile, chunkSize: 164 }, "/x.bin");
throw new Error("expected MALFORMED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_MALFORMED
);
expect((err as DriveTemplateError).context).toMatchObject({
file: "/x.bin",
});
}
});
it("totalChunks insufficient for size rejected", { timeout: 5000 }, () => {
try {
assertFile(
{ ...baseFile, totalChunks: 1, chunkSize: 1024, size: 4096 },
"/x.bin"
);
throw new Error("expected MALFORMED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_MALFORMED
);
}
});
it("valid file passes through assertFile (positive path)", {
timeout: 5000,
}, () => {
expect(() => assertFile(baseFile, "/x.bin")).not.toThrow();
});
it("empty manifestKey rejected", { timeout: 5000 }, () => {
try {
assertFile({ ...baseFile, manifestKey: "" }, "/x.bin");
throw new Error("expected MALFORMED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_MALFORMED
);
}
});
it("malformed checksum rejected", { timeout: 5000 }, () => {
try {
assertFile({ ...baseFile, checksum: "md5:abc" }, "/x.bin");
throw new Error("expected MALFORMED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_MALFORMED
);
}
});
it("empty manifestSig rejected", { timeout: 5000 }, () => {
try {
assertFile(
{ ...baseFile, manifestSig: new Uint8Array(0) },
"/x.bin"
);
throw new Error("expected MALFORMED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_MALFORMED
);
}
});
it("nested folder: deepest file's failure surfaces with full path", {
timeout: 5000,
}, () => {
const tree: FolderTemplate = {
name: "/",
children: [
{
name: "photos",
children: [
{
name: "wedding",
children: [{ ...baseFile, chunkSize: 164 }],
},
],
},
],
};
try {
assertFolderFileInvariants(tree);
throw new Error("expected MALFORMED");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_MALFORMED
);
const ctx = (err as DriveTemplateError).context as { file: string };
expect(ctx.file).toContain("photos");
expect(ctx.file).toContain("wedding");
}
});
});

View File

@@ -0,0 +1,94 @@
/**
* DTP-06 — replay protection via per-client nonce LRU.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type DriveTemplateError,
DTP_CODES,
TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY,
TemplateNonceLru,
validateTemplate,
} from "../src/index.js";
import { makeKeys, makeNonce, signed } from "./_helpers.js";
describe("DTP-06 — replay protection", () => {
it("hydrate with same (driveId, nonce) twice rejects on second call", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const lru = new TemplateNonceLru();
const tpl = signed(
{
issuer: keys.publicHex,
nonce: makeNonce(0x42),
},
keys
);
const ctx = { clock: () => Date.now(), nonceLru: lru };
validateTemplate(tpl, ctx);
try {
validateTemplate(tpl, ctx);
throw new Error("expected REPLAY rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_REPLAY
);
}
});
it("LRU capacity is the documented default", { timeout: 5000 }, () => {
expect(TEMPLATE_NONCE_LRU_DEFAULT_CAPACITY).toBe(1024);
});
it("LRU evicts oldest entry past capacity", { timeout: 5000 }, () => {
const lru = new TemplateNonceLru(2);
const a = makeNonce(1);
const b = makeNonce(2);
const c = makeNonce(3);
lru.remember("d", a);
lru.remember("d", b);
lru.remember("d", c);
expect(lru.has("d", a)).toBe(false);
expect(lru.has("d", b)).toBe(true);
expect(lru.has("d", c)).toBe(true);
expect(lru.size).toBe(2);
});
it("LRU reinserts on duplicate to refresh recency", {
timeout: 5000,
}, () => {
const lru = new TemplateNonceLru(2);
const a = makeNonce(1);
const b = makeNonce(2);
lru.remember("d", a);
lru.remember("d", b);
lru.remember("d", a); // refresh
const c = makeNonce(3);
lru.remember("d", c); // evicts b, not a
expect(lru.has("d", a)).toBe(true);
expect(lru.has("d", b)).toBe(false);
expect(lru.has("d", c)).toBe(true);
});
it("LRU rejects non-positive capacity", { timeout: 5000 }, () => {
expect(() => new TemplateNonceLru(0)).toThrow();
expect(() => new TemplateNonceLru(-1)).toThrow();
expect(() => new TemplateNonceLru(1.5)).toThrow();
});
it("clear() empties the LRU", { timeout: 5000 }, () => {
const lru = new TemplateNonceLru(8);
lru.remember("d", makeNonce(1));
expect(lru.size).toBe(1);
lru.clear();
expect(lru.size).toBe(0);
});
it("capacity getter exposes the configured cap", {
timeout: 5000,
}, () => {
const lru = new TemplateNonceLru(99);
expect(lru.capacity).toBe(99);
});
});

View File

@@ -0,0 +1,83 @@
/**
* DTP-01 / DTP-03 — DriveTemplate shape + canonical serialization
* deterministic across calls.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { canonicalSerialize } from "../src/codec/canonical.js";
import type { FileTemplate, FolderTemplate } from "../src/index.js";
import { signTemplate, verifyTemplateSignature } from "../src/index.js";
import { buildTemplate, DEFAULT_POLICY, makeKeys } from "./_helpers.js";
const FILE: FileTemplate = {
name: "photo.jpg",
fileLocId: "loc-1",
fileId: "f".repeat(64),
size: 1024,
totalChunks: 1,
chunkSize: 1024,
checksum: "blake3:abcd",
flags: DEFAULT_POLICY,
manifestKey: "manifest:f",
manifestSig: new Uint8Array(64).fill(1),
};
const ROOT: FolderTemplate = {
name: "/",
children: [{ name: "photos", children: [FILE] }],
};
describe("DTP-01 — DriveTemplate shape", () => {
it("DriveTemplate with all required fields encodes/decodes", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex, rootFolder: ROOT }),
keys.secret
);
expect(verifyTemplateSignature(tpl)).toBe(true);
});
it("Minimal empty-rootFolder template signs and verifies", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex }),
keys.secret
);
expect(verifyTemplateSignature(tpl)).toBe(true);
});
});
describe("DTP-03 — canonicalSerialize is deterministic", () => {
it("canonicalSerialize is deterministic across calls", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = buildTemplate({ issuer: keys.publicHex, rootFolder: ROOT });
const a = canonicalSerialize(tpl);
const b = canonicalSerialize(tpl);
expect(a).toEqual(b);
});
it("Field-reorder produces identical bytes", { timeout: 5000 }, () => {
const keys = makeKeys();
const a = buildTemplate({ issuer: keys.publicHex, rootFolder: ROOT });
// Recreate with fields in a different in-memory order — the
// canonical encoder's fixed schema-driven order means the bytes
// are still identical.
const reordered = {
policy: a.policy,
schemaVersion: a.schemaVersion,
issuedAt: a.issuedAt,
issuer: a.issuer,
driveId: a.driveId,
expiresAt: a.expiresAt,
nonce: a.nonce,
rootFolder: a.rootFolder,
} as typeof a;
expect(canonicalSerialize(a)).toEqual(canonicalSerialize(reordered));
});
});

View File

@@ -0,0 +1,68 @@
/**
* DTP-05 step 3 / DTP-07 — issuance window enforcement.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type DriveTemplateError,
DTP_CODES,
ISSUANCE_WINDOW_MS,
TemplateNonceLru,
validateTemplate,
} from "../src/index.js";
import { makeKeys, signed } from "./_helpers.js";
describe("DTP-07 — 24-hour issuance window", () => {
it("ISSUANCE_WINDOW_MS pinned at 24h", { timeout: 5000 }, () => {
expect(ISSUANCE_WINDOW_MS).toBe(24 * 60 * 60 * 1000);
});
it("template issued 24h+1s ago rejected as STALE", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const NOW = 5_000_000_000;
const issuedAt = NOW - ISSUANCE_WINDOW_MS - 1_000;
const tpl = signed(
{
issuer: keys.publicHex,
issuedAt,
expiresAt: NOW + 60_000,
},
keys
);
const ctx = {
clock: () => NOW,
nonceLru: new TemplateNonceLru(),
};
try {
validateTemplate(tpl, ctx);
throw new Error("expected STALE");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_STALE
);
}
});
it("template issued just under 24h ago accepted", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const NOW = 5_000_000_000;
const issuedAt = NOW - ISSUANCE_WINDOW_MS + 1_000;
const tpl = signed(
{
issuer: keys.publicHex,
issuedAt,
expiresAt: NOW + 60_000,
},
keys
);
const ctx = {
clock: () => NOW,
nonceLru: new TemplateNonceLru(),
};
expect(() => validateTemplate(tpl, ctx)).not.toThrow();
});
});

View File

@@ -0,0 +1,152 @@
/**
* DTP-04 — single bit flip in any field causes signature verification
* to fail. Property test using fast-check picks a random byte position
* inside the signed canonical bytes and flips one bit; verification
* MUST throw `DRIVE.TEMPLATE_SIG_INVALID`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { canonicalSerialize } from "../src/codec/canonical.js";
import {
DriveTemplateError,
DTP_CODES,
signTemplate,
verifyTemplateSignature,
} from "../src/index.js";
import { buildTemplate, makeKeys, makeNonce } from "./_helpers.js";
describe("DTP-04 — server signature covers every field", () => {
it("single bit flip in any field causes signature failure", {
timeout: 10_000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex }),
keys.secret
);
expect(verifyTemplateSignature(tpl)).toBe(true);
// Property: for any byte position inside the canonical bytes,
// a flipped bit must make verification fail.
const canonical = canonicalSerialize(tpl);
fc.assert(
fc.property(
fc.integer({ min: 0, max: canonical.length - 1 }),
fc.integer({ min: 0, max: 7 }),
(bytePos, bitPos) => {
// Flip the bit in the nonce field (byte index inside nonce).
const flippedNonce = new Uint8Array(tpl.nonce);
if (bytePos < flippedNonce.length) {
flippedNonce[bytePos] =
(flippedNonce[bytePos] ?? 0) ^ (1 << bitPos);
const tampered = { ...tpl, nonce: flippedNonce };
try {
verifyTemplateSignature(tampered);
return false; // verification should not pass
} catch (err) {
return (
err instanceof DriveTemplateError &&
err.code === DTP_CODES.TEMPLATE_SIG_INVALID
);
}
}
return true;
}
),
{ numRuns: 30 }
);
});
it("flipping issuedAt fails verification", { timeout: 5000 }, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex }),
keys.secret
);
const tampered = { ...tpl, issuedAt: tpl.issuedAt + 1 };
try {
verifyTemplateSignature(tampered);
throw new Error("expected sig failure");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_SIG_INVALID
);
}
});
it("flipping nonce fails verification", { timeout: 5000 }, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex, nonce: makeNonce(0xab) }),
keys.secret
);
const tampered = { ...tpl, nonce: makeNonce(0xcd) };
try {
verifyTemplateSignature(tampered);
throw new Error("expected sig failure");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_SIG_INVALID
);
}
});
it("verification under wrong issuer key fails", { timeout: 5000 }, () => {
const k1 = makeKeys();
const k2 = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: k1.publicHex }),
k1.secret
);
const wrongIssuerTpl = { ...tpl, issuer: k2.publicHex };
try {
verifyTemplateSignature(wrongIssuerTpl);
throw new Error("expected sig failure");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_SIG_INVALID
);
}
});
it("invalid issuer hex rejected with TEMPLATE_SIG_INVALID", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex }),
keys.secret
);
const broken = { ...tpl, issuer: "not-hex-zzz" };
try {
verifyTemplateSignature(broken);
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_SIG_INVALID
);
}
});
it("malformed signature buffer (wrong length) rejected via catch arm", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex }),
keys.secret
);
const broken = {
...tpl,
serverSignature: new Uint8Array(8),
};
try {
verifyTemplateSignature(broken);
throw new Error("expected rejection");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_SIG_INVALID
);
}
});
});

View File

@@ -0,0 +1,43 @@
/**
* DTP-08 — folder depth cap.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FolderTemplate } from "../src/index.js";
import {
assertFolderDepth,
type DriveTemplateError,
DTP_CODES,
MAX_FOLDER_DEPTH,
} from "../src/index.js";
function chain(depth: number): FolderTemplate {
let node: FolderTemplate = { name: `level-${depth}`, children: [] };
for (let i = depth - 1; i >= 0; i--) {
node = { name: `level-${i}`, children: [node] };
}
return node;
}
describe("DTP-08 — folder depth cap", () => {
it("MAX_FOLDER_DEPTH pinned at 32", { timeout: 5000 }, () => {
expect(MAX_FOLDER_DEPTH).toBe(32);
});
it("depth-32 nested folder accepted", { timeout: 5000 }, () => {
const tree = chain(32);
expect(() => assertFolderDepth(tree)).not.toThrow();
});
it("depth-33 nested folder rejected", { timeout: 5000 }, () => {
const tree = chain(33);
try {
assertFolderDepth(tree);
throw new Error("expected TOO_DEEP");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_TOO_DEEP
);
}
});
});

View File

@@ -0,0 +1,98 @@
/**
* DTP-05 step 5 — TEMPLATE_UNTRUSTED_ISSUER rejection.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type DriveTemplateError,
DTP_CODES,
signTemplate,
TemplateNonceLru,
validateTemplate,
} from "../src/index.js";
import { buildTemplate, emptyRoot, makeKeys } from "./_helpers.js";
describe("DTP-05 step 5 — trusted issuer gate", () => {
it("issuer not in trustedKeyAuthorities rejected", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
{
...buildTemplate({ issuer: keys.publicHex }),
policy: {
defaultPolicy: {
autoDownload: false,
meshAllowed: true,
cdnAllowed: true,
},
trustedKeyAuthorities: ["someone-else"],
},
},
keys.secret
);
const ctx = {
clock: () => Date.now(),
nonceLru: new TemplateNonceLru(),
};
try {
validateTemplate(tpl, ctx);
throw new Error("expected UNTRUSTED_ISSUER");
} catch (err) {
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_UNTRUSTED_ISSUER
);
}
});
it("empty trustedKeyAuthorities permits any issuer (no allowlist)", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
{
...buildTemplate({ issuer: keys.publicHex }),
policy: {
defaultPolicy: {
autoDownload: false,
meshAllowed: true,
cdnAllowed: true,
},
trustedKeyAuthorities: [],
},
},
keys.secret
);
const ctx = {
clock: () => Date.now(),
nonceLru: new TemplateNonceLru(),
};
expect(() => validateTemplate(tpl, ctx)).not.toThrow();
});
it("issuer in allowlist accepted", { timeout: 5000 }, () => {
const keys = makeKeys();
const tpl = signTemplate(
{
...buildTemplate({
issuer: keys.publicHex,
rootFolder: emptyRoot(),
}),
policy: {
defaultPolicy: {
autoDownload: false,
meshAllowed: true,
cdnAllowed: true,
},
trustedKeyAuthorities: [keys.publicHex],
},
},
keys.secret
);
const ctx = {
clock: () => Date.now(),
nonceLru: new TemplateNonceLru(),
};
expect(() => validateTemplate(tpl, ctx)).not.toThrow();
});
});

View File

@@ -0,0 +1,117 @@
/**
* Forward-defensive catch arms in `codec/verify`, `key/grant-validator`,
* and `cdn/upload-token` — exercised here by injecting a throwing
* `verifyFn` so the catch path runs in production code rather than
* being papered over with `istanbul ignore` annotations. Per AGENTS.md
* §7a, defensive guards must have a real test exercising them.
*/
import { sign } from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { KeyGrantPayload } from "../src/index.js";
import {
canonicalSerializeKeyGrant,
DriveTemplateError,
DTP_CODES,
signTemplate,
signUploadToken,
validateKeyGrant,
verifyTemplateSignature,
verifyUploadToken,
} from "../src/index.js";
import { buildTemplate, makeKeys } from "./_helpers.js";
const NOW = 2_000_000_000;
function throwingVerify(): never {
throw new Error("synthetic verify failure");
}
describe("AGENTS §7a — verify catch arms", () => {
it("verifyTemplateSignature surfaces a thrown verifier as TEMPLATE_SIG_INVALID", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tpl = signTemplate(
buildTemplate({ issuer: keys.publicHex }),
keys.secret
);
try {
verifyTemplateSignature(tpl, throwingVerify);
throw new Error("expected SIG_INVALID");
} catch (err) {
expect(err).toBeInstanceOf(DriveTemplateError);
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.TEMPLATE_SIG_INVALID
);
expect((err as DriveTemplateError).message).toContain(
"synthetic verify failure"
);
}
});
it("validateKeyGrant surfaces a thrown verifier as KEY_GRANT_REJECTED", {
timeout: 5000,
}, () => {
const issuer = makeKeys();
const recipient = makeKeys();
const base: Omit<KeyGrantPayload, "issuerSignature"> = {
fileId: "f".repeat(64),
recipient: recipient.publicHex,
wrappedKey: new Uint8Array(48).fill(7),
issuer: issuer.publicHex,
expiresAt: NOW + 60_000,
};
const canonical = canonicalSerializeKeyGrant(base);
const grant: KeyGrantPayload = {
...base,
issuerSignature: sign(canonical, issuer.secret),
};
try {
validateKeyGrant(grant, {
expectedKeyAuthority: issuer.publicHex,
ourPubkey: recipient.publicHex,
clock: () => NOW,
verifyFn: throwingVerify,
});
throw new Error("expected KEY_GRANT_REJECTED");
} catch (err) {
expect(err).toBeInstanceOf(DriveTemplateError);
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.KEY_GRANT_REJECTED
);
expect((err as DriveTemplateError).message).toContain(
"synthetic verify failure"
);
}
});
it("verifyUploadToken surfaces a thrown verifier as CDN_BAD_SIGNATURE", {
timeout: 5000,
}, () => {
const keys = makeKeys();
const tok = signUploadToken(
{
fileId: "f".repeat(64),
chunkId: 0,
chunkHash: "blake3:abc",
uploaderPubkey: "uploader",
expiresAt: NOW + 30_000,
},
keys.secret,
{ clock: () => NOW }
);
try {
verifyUploadToken(tok, keys.publicHex, () => NOW, throwingVerify);
throw new Error("expected CDN_BAD_SIGNATURE");
} catch (err) {
expect(err).toBeInstanceOf(DriveTemplateError);
expect((err as DriveTemplateError).code).toBe(
DTP_CODES.CDN_BAD_SIGNATURE
);
expect((err as DriveTemplateError).message).toContain(
"synthetic verify failure"
);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/drive-template",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@noble/curves",
"@noble/curves/ed25519",
"@noble/hashes",
"@noble/hashes/blake3",
"@noble/hashes/sha256",
"@opentelemetry/api",
"@suaveplan/crypto-curve",
"@suaveplan/crypto-curve/ed25519",
"@suaveplan/crypto-utils",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();