Compare commits
3 Commits
v4.11.1
...
b44acf867b
| Author | SHA1 | Date | |
|---|---|---|---|
| b44acf867b | |||
| 8c67a00f37 | |||
| 306bf08452 |
13
.scaffold/log.md
Normal file
13
.scaffold/log.md
Normal 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 0x31–0x33. Låst beslutning: stream-ratchets persisteres aldri — en droppet stream gjenåpnes, aldri gjenopptas.
|
||||
|
||||
## 2026-05-09 — Android M-Cross 1–4 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.1–V3.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
27
.scaffold/plan.md
Normal 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
17
.scaffold/tasks.yaml
Normal 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: []
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
4
CLAUDE.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user