3 Commits

Author SHA1 Message Date
b44acf867b release(files): prepare 4.11.2 hotfix
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled
2026-07-10 17:41:25 +02:00
8c67a00f37 fix(files): await pull-mode stream readiness 2026-07-10 17:38:35 +02:00
306bf08452 chore: adopter Scaffold plan-kontrakt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:04:48 +02:00
11 changed files with 189 additions and 8 deletions

13
.scaffold/log.md Normal file
View File

@@ -0,0 +1,13 @@
# Logg — Shade
## 2026-07-09 — Adoptert Scaffold plan-kontrakt
Migrerte `docs/ROADMAP.md` og `android/shade-android/ROADMAP-ANDROID.md` til `.scaffold/`: nåbildet destillert inn i `plan.md`, originalene arkivert under `.scaffold/archive/` med opprinnelig mappestruktur. Versjonsdesigndokumentene (`docs/V5.0.md`, `docs/archive/V*.md`) er bevisst latt stå — de er spesifikasjoner, ikke planfiler.
## 2026-05-15 — v4.11: streaming Double-Ratchet sub-sessions
Siste post-GA-økning på 4.x-linjen: `ShadeStream` gir in-memory sub-sessions (seal/open uten keystore-I/O per frame) med nye wire-typer 0x310x33. Låst beslutning: stream-ratchets persisteres aldri — en droppet stream gjenåpnes, aldri gjenopptas.
## 2026-05-09 — Android M-Cross 14 fullført + Keystore-adapter
All kryptografisk paritet TS↔Kotlin grønn via delte test-vektorer: KDF-kjede, HKDF-labels, X3DH, ratchet-steg, fingerprint, wire 0x02 og 0x11 (streams), backup-HKDF, group sender-keys og storage-HKDF. Etterslepet fra Android-roadmapen landet samme dag: scrypt + argon2id via Bouncy Castle og `shade-android-keystore`-modulen (hardware-backed master key + `KeystoreStorage`). Gjenstår: socket-interop-test og instrumenterte Keystore-tester.
## 2026-05-03 — V4.0 GA: V3.x-konsolidering og audit-pakking
Alle fasene V3.1V3.12 ferdige og merget: dokumentasjon/hardening, at-rest storage-kryptering, trust-UX, observability (OTel), Android-paritet, inbox (store-and-forward), transport-bridge, web workers-krypto, filmetadata, social key recovery, WebRTC P2P og key transparency. Wire-formatet låst — uendret fra 0.4.x, så 4.0-peers interopererer byte-for-byte med 0.4.x. Kjernen pakket for ekstern review. Låst beslutning: alt VOIP/video skilt ut til V5.0, bygget oppå den frosne 4.0-stacken via reserverte envelope-typer (ikke breaking).

27
.scaffold/plan.md Normal file
View File

@@ -0,0 +1,27 @@
---
project: "Shade"
phase: "Post-4.x GA — V5.0 (Voice & Video) i idéfase"
updated: 2026-07-09
focus: "4.x-kjernen er GA-frosset og revisjonsklar; neste løft er V5.0 sanntid oppå den låste stacken"
blockers: []
---
# Plan — Shade
## Mål
- E2EE-bibliotek som implementerer Signal-protokollen (X3DH + Double Ratchet) for TypeScript/Bun — drop-in for frontend, backend og mobil, med forward secrecy og post-compromise recovery.
- Byte-for-byte kryssplattform-paritet: Kotlin/Android-porten verifiseres kontinuerlig mot TS-referansen via delte test-vektorer i `test-vectors/`.
- GA-frosset, revisjonsklar 4.0-kjerne som fundament; nye kapabiliteter (sanntid i V5.0) bygges oppå uten å røre kjernekrypto-revisjonen.
## Nå
- [ ] V5.0 fra `Idea` til `Design`: designnotatet `docs/V5.0.md` (SFrame-style frame encryption, `@shade/voice` / `@shade/video` / `@shade/broadcast`) må modnes og godkjennes
- [ ] Ende-til-ende interop-test TS-server ↔ Kotlin-klient over ekte socket (anbefalt før «production»-label på Android)
- [ ] Instrumenterte tester for `KeystoreStorage` (krever Android-emulator/enhet — utsatt i v4.9/v4.10-porten)
## Neste
- V5.0-implementasjon: `@shade/voice` (1:1 voice) → `@shade/video``@shade/broadcast` (1:N med relay-helper)
- «Production»-label på Android når interop-testen er grønn
## Senere
- Re-pinne backup-vektoren når begge porter bytter til argon2id som passord-KDF (HKDF-placeholder flagget i threat-model; `deriveMasterKeyArgon2id` finnes allerede i Kotlin)
- Versjonsdesigndokumentene bor fortsatt i `docs/V5.0.md` og `docs/archive/V*.md` — de er spesifikasjoner, ikke planfiler

17
.scaffold/tasks.yaml Normal file
View File

@@ -0,0 +1,17 @@
# Scaffold kanban-kort. Skjema per kort:
# - id: kort-slug # unik i fila
# title: ""
# description: ""
# status: todo # todo | doing | done | blocked
# column: todo # speiler status (kanban-kolonne)
# priority: medium # low | medium | high
# assignee: human # cursor | claude | human | any
# context: "" # valgfri: hvorfor/hvor i koden
# acceptance: "" # valgfri: hva «ferdig» betyr
# todos:
# - text: "Underoppgave"
# done: false
# created: 2026-07-09
# updated: 2026-07-09
# completed_at: null
tasks: []

View File

@@ -5,6 +5,20 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.11.2] — 2026-07-10 — Files pull-mode startup hotfix
**`@shade/files`**
- Fix a construction race in HTTP pull-mode clients: an immediate streamed
read/write now awaits asynchronous stream-bridge registration instead of
treating the not-yet-created queue drainer as proof that the client is
inline-only.
- Add a deterministic integration test that holds incoming-transfer
registration, starts a 512 KiB write immediately, and verifies that it
remains pending until streaming is ready.
- Add `publish-all.ts --only <pkg>` so isolated package hotfixes do not publish
unrelated workspace packages.
## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions
Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2

4
CLAUDE.md Normal file
View File

@@ -0,0 +1,4 @@
## Planlegging
Planen for dette prosjektet bor i `.scaffold/`: `plan.md` er nåbildet (hold den kort,
bump `updated` i frontmatter ved endring), `log.md` er append-only historikk (nyeste øverst),
`tasks.yaml` er kanban. Ikke opprett planfiler andre steder.

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/files",
"version": "4.11.1",
"version": "4.11.2",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -444,8 +444,12 @@ export function createFilesHttpClient(
};
return out;
}
// Streamed read — only supported when the queue drainer is wired.
if (drainer === null) {
// Streamed read — only supported when pull-mode was configured.
// `drainer` is assigned asynchronously after the streams bridge has
// subscribed, so checking it here races client construction. The
// promise itself is the synchronous configuration signal; awaiting it
// also guarantees the drainer has been installed by its `.then()`.
if (streamsBridgePromise === null) {
throw new InternalFileError(
`http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`,
);
@@ -507,8 +511,11 @@ export function createFilesHttpClient(
);
}
// Streamed write — requires the queue drainer + streams-bridge.
if (drainer === null) {
// Streamed write — requires pull-mode configuration. Do not inspect
// `drainer` as a readiness flag: bridge registration is asynchronous,
// and an immediate large write must wait for it instead of being
// misclassified as an inline-only client.
if (streamsBridgePromise === null) {
throw new ConflictError(
`http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`,
);

View File

@@ -7,8 +7,10 @@ import {
} from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { Hono } from 'hono';
import { createFilesHttpClient, type FileEntry } from '../../src/index.js';
const crypto = new SubtleCryptoProvider();
type ShadeInstance = Awaited<ReturnType<typeof createShade>>;
/**
* Stand up the full pull-mode rig:
@@ -20,7 +22,8 @@ const crypto = new SubtleCryptoProvider();
* no inbound listener, streams supported via long-poll.
*/
async function setupPullRig(opts: {
bobHandler: Parameters<NonNullable<Awaited<ReturnType<typeof createShade>>['files']>['serve']>[0];
bobHandler: Parameters<NonNullable<ShadeInstance['files']>['serve']>[0];
wrapClientShade?: (shade: ShadeInstance) => Parameters<typeof createFilesHttpClient>[0];
}) {
const prekey = createPrekeyServer({
crypto,
@@ -46,7 +49,7 @@ async function setupPullRig(opts: {
const bobServer = Bun.serve({ port: 0, fetch: app.fetch });
const baseUrl = `http://localhost:${bobServer.port}`;
const fs = alice.files.httpClient('bob', {
const fs = createFilesHttpClient(opts.wrapClientShade?.(alice) ?? alice, 'bob', {
rpcUrl: `${baseUrl}/rpc`,
outboundQueueUrl: `${baseUrl}/queue`,
transferBaseUrl: baseUrl,
@@ -70,6 +73,83 @@ async function setupPullRig(opts: {
}
describe('@shade/files HTTP RPC — pull-mode streams', () => {
test('immediate streamed write waits for asynchronous stream-bridge registration', async () => {
const payload = new Uint8Array(512 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 53) & 0xff;
let releaseRegistration!: () => void;
const registrationGate = new Promise<void>((resolve) => {
releaseRegistration = resolve;
});
let registrationStarted!: () => void;
const started = new Promise<void>((resolve) => {
registrationStarted = resolve;
});
const rig = await setupPullRig({
wrapClientShade: (alice) =>
new Proxy(alice, {
get(target, property) {
if (property === 'onIncomingTransfer') {
return async (handler: Parameters<typeof target.onIncomingTransfer>[0]) => {
registrationStarted();
await registrationGate;
return await target.onIncomingTransfer(handler);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
}),
bobHandler: {
write: async (ctx) => {
const content = ctx.args.content;
if (content.kind !== 'streams') throw new Error('expected streamed content');
const reader = content.stream.getReader();
let received = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
received += value?.byteLength ?? 0;
}
reader.releaseLock();
await content.sha256;
const entry: FileEntry = {
name: 'immediate.bin',
kind: 'file',
size: received,
mtime: Date.now(),
metadata: {},
};
return { entry };
},
},
});
try {
let settled = false;
const write = rig.fs.write('/immediate.bin', payload);
void write.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await started;
await Bun.sleep(10);
expect(settled).toBe(false);
releaseRegistration();
const result = await write;
expect(result.entry.size).toBe(payload.byteLength);
} finally {
releaseRegistration();
await rig.teardown();
}
}, 15_000);
test('streamed read (4 MiB) via long-poll queue', async () => {
const payload = new Uint8Array(4 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff;

View File

@@ -13,6 +13,7 @@
*
* Optional:
* DRY_RUN=1 — pack tarballs but do not publish (no token required)
* --only <pkg> — publish one package (e.g. shade-files or @shade/files)
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
@@ -51,10 +52,27 @@ const PACKAGES = [
const REGISTRY_HOST = 'gt.zyon.no';
const ROOT = join(import.meta.dir, '..');
function selectedPackages(argv: string[]): string[] {
const onlyIndex = argv.indexOf('--only');
if (onlyIndex === -1) return PACKAGES;
const requested = argv[onlyIndex + 1];
if (!requested) throw new Error('Usage: publish-all.ts --only <shade-files|@shade/files>');
const normalized = requested.startsWith('@shade/')
? `shade-${requested.slice('@shade/'.length)}`
: requested.startsWith('shade-')
? requested
: `shade-${requested}`;
if (!PACKAGES.includes(normalized)) {
throw new Error(`Unknown Shade package: ${requested}`);
}
return [normalized];
}
async function main() {
const token = process.env.GITEA_TOKEN;
const user = process.env.GITEA_USER ?? 'Stian';
const dryRun = process.env.DRY_RUN === '1';
const packagesToPublish = selectedPackages(process.argv.slice(2));
if (!token && !dryRun) {
console.error('GITEA_TOKEN is required (or set DRY_RUN=1)');
@@ -64,6 +82,7 @@ async function main() {
const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`;
console.log(`Target registry: ${registryUrl}`);
console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`);
console.log(`Packages: ${packagesToPublish.join(', ')}`);
console.log();
const npmrcPath = join(ROOT, '.npmrc.publish');
@@ -86,7 +105,7 @@ async function main() {
let alreadyPublished = 0;
let failed = 0;
for (const pkg of PACKAGES) {
for (const pkg of packagesToPublish) {
const pkgDir = join(ROOT, 'packages', pkg);
const pkgJsonPath = join(pkgDir, 'package.json');
if (!existsSync(pkgJsonPath)) {