release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,146 @@
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const here = dirname(fileURLToPath(import.meta.url));
const specPath = join(here, '..', 'openapi.yaml');
const spec = Bun.YAML.parse(readFileSync(specPath, 'utf-8')) as Record<string, unknown>;
describe('openapi.yaml lint', () => {
test('declares OpenAPI 3.1', () => {
expect(typeof spec.openapi).toBe('string');
expect(String(spec.openapi).startsWith('3.1')).toBe(true);
});
test('has the required top-level fields', () => {
expect(spec).toHaveProperty('info');
expect(spec).toHaveProperty('paths');
expect(spec).toHaveProperty('components');
const info = spec.info as Record<string, unknown>;
expect(typeof info.title).toBe('string');
expect(typeof info.version).toBe('string');
});
test('every operation has summary + responses', () => {
const paths = (spec.paths ?? {}) as Record<string, Record<string, unknown>>;
const httpMethods = new Set([
'get',
'post',
'put',
'patch',
'delete',
'options',
'head',
]);
for (const [routePath, route] of Object.entries(paths)) {
for (const [verb, op] of Object.entries(route)) {
if (!httpMethods.has(verb)) continue;
const operation = op as Record<string, unknown>;
expect(operation.summary, `${verb.toUpperCase()} ${routePath} missing summary`).toBeDefined();
const responses = operation.responses as Record<string, unknown> | undefined;
expect(responses, `${verb.toUpperCase()} ${routePath} missing responses`).toBeDefined();
expect(
Object.keys(responses ?? {}).length,
`${verb.toUpperCase()} ${routePath} has empty responses`,
).toBeGreaterThan(0);
}
}
});
test('every $ref resolves to a defined component', () => {
const refs = collectRefs(spec);
expect(refs.length).toBeGreaterThan(0);
for (const ref of refs) {
expect(
ref.startsWith('#/'),
`non-internal $ref not allowed: ${ref}`,
).toBe(true);
const segments = ref.slice(2).split('/');
let cursor: unknown = spec;
for (const segment of segments) {
expect(
isObject(cursor),
`dangling $ref: ${ref} (failed at "${segment}")`,
).toBe(true);
cursor = (cursor as Record<string, unknown>)[segment];
}
expect(cursor, `unresolved $ref: ${ref}`).toBeDefined();
}
});
test('every security requirement references a defined scheme', () => {
const schemes = (
((spec.components ?? {}) as Record<string, Record<string, unknown>>)
.securitySchemes ?? {}
) as Record<string, unknown>;
const definedSchemes = new Set(Object.keys(schemes));
const requirements = collectSecurityRequirements(spec);
for (const name of requirements) {
expect(
definedSchemes.has(name),
`security requirement "${name}" not defined under components.securitySchemes`,
).toBe(true);
}
});
test('declares the V3.1 transfer surface', () => {
const paths = (spec.paths ?? {}) as Record<string, unknown>;
expect(paths['/v1/transfer/health']).toBeDefined();
expect(paths['/v1/transfer/{streamId}/chunk']).toBeDefined();
expect(paths['/v1/transfer/{streamId}/state']).toBeDefined();
expect(paths['/v1/transfer/control']).toBeDefined();
const schemes = (
((spec.components ?? {}) as Record<string, Record<string, unknown>>)
.securitySchemes ?? {}
) as Record<string, unknown>;
expect(schemes.ShadeTransferAuthenticator).toBeDefined();
});
});
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function collectRefs(node: unknown, out: string[] = []): string[] {
if (Array.isArray(node)) {
for (const item of node) collectRefs(item, out);
return out;
}
if (!isObject(node)) return out;
for (const [key, value] of Object.entries(node)) {
if (key === '$ref' && typeof value === 'string') {
out.push(value);
continue;
}
collectRefs(value, out);
}
return out;
}
function collectSecurityRequirements(spec: Record<string, unknown>): Set<string> {
const names = new Set<string>();
const collectFromList = (list: unknown): void => {
if (!Array.isArray(list)) return;
for (const entry of list) {
if (!isObject(entry)) continue;
for (const name of Object.keys(entry)) names.add(name);
}
};
collectFromList(spec.security);
const paths = (spec.paths ?? {}) as Record<string, Record<string, unknown>>;
for (const route of Object.values(paths)) {
for (const value of Object.values(route)) {
if (!isObject(value)) continue;
collectFromList(value.security);
}
}
return names;
}