@ai-wayfinding/core 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Wayfinding protocol core
2
+
3
+ `@ai-wayfinding/core` supplies the portable types and pure protocol operations for encrypted team spaces. An **enclave** is the technical name in code for that space; a **journey** is what people call it. This package has no server, network client, or storage adapter. It uses Web Crypto and age encryption in Node 22+, browsers, and Cloudflare Workers.
4
+
5
+ ## Using the package
6
+
7
+ Install dependencies at the repository root with `npm ci`. Run `npm run typecheck -w packages/core`, `npm run build -w packages/core`, and `npm test -w packages/core`. Run the same tests in workerd with `npm run test:workers -w packages/core` and in Chromium with `npm run test:browser -w packages/core`. If your Chromium installation differs from Playwright's, set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to its executable.
8
+
9
+ The top-level module exports `newId`, `createAgeIdentity`, `createAgentIdentity`, `createSigningIdentity`, `sealIdentity`, `openIdentity`, `generateJourneyKey`, `wrapJourneyKey`, `rotateJourneyKey`, `seal`, `open`, `parseRecord`, `serializeRecord`, `signEntry`, `verifyLog`, `removeAndRotate`, `exportJourney`, and `importJourney`. Age recipients and identities may also be custom implementations, including browser-only passkey recipients. An agent's X25519 private `CryptoKey` is non-extractable; a person's age identity and Ed25519 private key are strings that can be sealed to recipients for storage. Store neither unsealed private material nor a usable journey key on a server.
10
+
11
+ ## Formats
12
+
13
+ An envelope has `{ outside, nonce, ciphertext }`. Only `outside` is plain: `{ v:1, id, journey, seq?, epoch, size, createdAt }`. `size` is the UTF-8 byte length of the encrypted JSON before encryption. `nonce` and `ciphertext` are base64. The decrypted JSON carries `{ type, typeVersion, body }`, with additional fields preserved. The twelve-byte AES-GCM nonce is fresh on each seal. Authenticated associated data covers **every** outside field, including `seq` when present; therefore, if the server assigns a sequence, it must reserve the sequence **before** the client seals the record. Changing it afterwards fails decryption. See [the public protocol](../../docs/journey-protocol.md) for item, comment, deletion, and membership details.
14
+
15
+ A signed membership entry is `{ v:1, seq, prev, at, actor, type, body, sig }`. For signatures, remove `sig`, sort object keys by JavaScript string ordering recursively, keep array order, render compact JSON, encode UTF-8 and sign with Ed25519. The signature is base64. `prev` is the base64 SHA-256 hash of the complete preceding entry in the same canonical encoding, or `null` at genesis. `verifyLog` returns either a derived state or a typed error with a failing sequence number. Unknown membership types return `client-too-old`. Genesis may include an optional signed `description` (up to 2,000 characters) and `journeyKind` (`individual` or `team`); both stay in the encrypted membership log, not the plain registry. The export file is an age-encrypted JSON archive of the log, envelopes, and epoch key wraps; imports verify the log and open each encrypted envelope with an available identity.
16
+
17
+ ## Threat model
18
+
19
+ - The server can see journey identifiers, sizes, sequence numbers, key epochs, timestamps, and a plain journey name. Membership entries must be encrypted by the client before server storage; storing the in-memory log format plainly would reveal member identifiers and grants. The server cannot decrypt an envelope without a member's key.
20
+ - The server can deny service, delay writes, omit records, replay an old complete log, or refuse a key rotation. Clients need an independently retained log checkpoint to detect rollback; this package does not provide one.
21
+ - The server cannot forge a verified membership change or a new `members.manage` holder without a current person's Ed25519 signing key. A client must call `verifyLog` before wrapping a key for anyone.
22
+ - A removed member cannot unwrap the new key epoch. They can keep everything they downloaded and any old key they already know.
23
+ - The server cannot alter an authenticated outside field or ciphertext without `open` failing. A client must still check the server's access policy and sequence allocation separately.
24
+ - A compromised member device or exported recovery identity can read whatever epochs that identity can unwrap. This package does not implement passkeys, account recovery, or secure device storage.
@@ -0,0 +1,5 @@
1
+ export declare const encode: (bytes: Uint8Array) => string;
2
+ export declare const decode: (text: string) => Uint8Array;
3
+ export declare const asBuffer: (bytes: Uint8Array) => ArrayBuffer;
4
+ export declare const utf8: (text: string) => Uint8Array;
5
+ export declare const text: (bytes: Uint8Array) => string;
package/dist/codec.js ADDED
@@ -0,0 +1,5 @@
1
+ export const encode = (bytes) => btoa(Array.from(bytes, b => String.fromCharCode(b)).join(''));
2
+ export const decode = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
3
+ export const asBuffer = (bytes) => Uint8Array.from(bytes).buffer;
4
+ export const utf8 = (text) => new TextEncoder().encode(text);
5
+ export const text = (bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
@@ -0,0 +1,18 @@
1
+ import type { JourneyKey } from './teamKey.js';
2
+ import type { ProtocolRecord } from './types.js';
3
+ export interface OuterMeta {
4
+ v: 1;
5
+ id: string;
6
+ journey: string;
7
+ seq?: number;
8
+ epoch: number;
9
+ size: number;
10
+ createdAt: string;
11
+ }
12
+ export interface Envelope {
13
+ outside: OuterMeta;
14
+ nonce: string;
15
+ ciphertext: string;
16
+ }
17
+ export declare function seal(inner: ProtocolRecord, outerMeta: Omit<OuterMeta, 'v' | 'size'>, journeyKey: JourneyKey): Promise<Envelope>;
18
+ export declare function open(envelope: Envelope, journeyKey: JourneyKey): Promise<ProtocolRecord>;
@@ -0,0 +1,34 @@
1
+ import { asBuffer, decode, encode, text, utf8 } from './codec.js';
2
+ const fields = ['createdAt', 'epoch', 'id', 'journey', 'seq', 'size', 'v'];
3
+ function aad(outside) {
4
+ if (Object.keys(outside).some(k => !fields.includes(k)))
5
+ throw new Error('Unexpected outside field');
6
+ return utf8(JSON.stringify(fields.map(k => [k, Object.hasOwn(outside, k) ? outside[k] : null])));
7
+ }
8
+ function checkKey(key, epoch) {
9
+ if (key.epoch !== epoch || key.key.length !== 32)
10
+ throw new Error('Wrong journey key epoch');
11
+ }
12
+ export async function seal(inner, outerMeta, journeyKey) {
13
+ checkKey(journeyKey, outerMeta.epoch);
14
+ const plaintext = utf8(JSON.stringify(inner));
15
+ const outside = { v: 1, id: outerMeta.id, journey: outerMeta.journey, ...(outerMeta.seq === undefined ? {} : { seq: outerMeta.seq }), epoch: outerMeta.epoch, size: plaintext.length, createdAt: outerMeta.createdAt };
16
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
17
+ const key = await crypto.subtle.importKey('raw', asBuffer(journeyKey.key), 'AES-GCM', false, ['encrypt']);
18
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: asBuffer(nonce), additionalData: asBuffer(aad(outside)) }, key, asBuffer(plaintext));
19
+ return { outside, nonce: encode(nonce), ciphertext: encode(new Uint8Array(ciphertext)) };
20
+ }
21
+ export async function open(envelope, journeyKey) {
22
+ checkKey(journeyKey, envelope.outside.epoch);
23
+ const nonce = decode(envelope.nonce);
24
+ if (nonce.length !== 12)
25
+ throw new Error('Invalid nonce');
26
+ const key = await crypto.subtle.importKey('raw', asBuffer(journeyKey.key), 'AES-GCM', false, ['decrypt']);
27
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(nonce), additionalData: asBuffer(aad(envelope.outside)) }, key, asBuffer(decode(envelope.ciphertext))));
28
+ if (plaintext.length !== envelope.outside.size)
29
+ throw new Error('Invalid size');
30
+ const record = JSON.parse(text(plaintext));
31
+ if (!record || typeof record !== 'object' || Array.isArray(record) || typeof record.type !== 'string' || !Number.isSafeInteger(record.typeVersion) || !(record.body && typeof record.body === 'object' && !Array.isArray(record.body)))
32
+ throw new Error('Invalid record');
33
+ return record;
34
+ }
package/dist/ids.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function newId(): string;
2
+ export declare function isId(value: unknown): value is string;
package/dist/ids.js ADDED
@@ -0,0 +1,25 @@
1
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
2
+ let lastTime = -1;
3
+ let lastRandom = 0n;
4
+ const maxRandom = (1n << 80n) - 1n;
5
+ function base32(value, length) {
6
+ let result = '';
7
+ for (let i = 0; i < length; i++) {
8
+ result = alphabet[Number(value & 31n)] + result;
9
+ value >>= 5n;
10
+ }
11
+ return result;
12
+ }
13
+ export function newId() {
14
+ const now = Date.now();
15
+ if (now > lastTime || lastRandom === maxRandom) {
16
+ lastTime = Math.max(now, lastTime + (lastRandom === maxRandom ? 1 : 0));
17
+ lastRandom = crypto.getRandomValues(new BigUint64Array(2)).reduce((v, part) => (v << 64n) | part, 0n) & maxRandom;
18
+ }
19
+ else
20
+ lastRandom++;
21
+ return base32(BigInt(lastTime), 10) + base32(lastRandom, 16);
22
+ }
23
+ export function isId(value) {
24
+ return typeof value === 'string' && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(value);
25
+ }
@@ -0,0 +1,10 @@
1
+ export * from './types.js';
2
+ export * from './ids.js';
3
+ export * from './keys.js';
4
+ export * from './teamKey.js';
5
+ export * from './envelope.js';
6
+ export * from './items.js';
7
+ export * from './log.js';
8
+ export * from './removal.js';
9
+ export * from './transfer.js';
10
+ export * from './versions.js';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from './types.js';
2
+ export * from './ids.js';
3
+ export * from './keys.js';
4
+ export * from './teamKey.js';
5
+ export * from './envelope.js';
6
+ export * from './items.js';
7
+ export * from './log.js';
8
+ export * from './removal.js';
9
+ export * from './transfer.js';
10
+ export * from './versions.js';
@@ -0,0 +1,52 @@
1
+ import type { JsonObject, ProtocolRecord, Validation } from './types.js';
2
+ export interface ItemBody extends JsonObject {
3
+ id: string;
4
+ itemType: string;
5
+ title: string;
6
+ body: string;
7
+ author: string;
8
+ authoredBy: 'human' | 'agent' | 'mixed';
9
+ created: string;
10
+ tags: string[];
11
+ links?: {
12
+ to: string;
13
+ rel: string;
14
+ }[];
15
+ replaces?: string;
16
+ sharedFrom?: string;
17
+ resourceKind?: string;
18
+ }
19
+ export interface CommentBody extends JsonObject {
20
+ id: string;
21
+ item: string;
22
+ onVersion: string;
23
+ author: string;
24
+ authoredBy: 'human' | 'agent' | 'mixed';
25
+ at: string;
26
+ inReplyTo?: string;
27
+ body: string;
28
+ }
29
+ export interface DeleteBody extends JsonObject {
30
+ target: string;
31
+ }
32
+ export interface RecordDefinition {
33
+ name: string;
34
+ version: number;
35
+ validate(body: JsonObject): Validation;
36
+ upgrade?: (previous: JsonObject) => JsonObject;
37
+ }
38
+ export interface KnownRecord {
39
+ kind: 'known';
40
+ record: ProtocolRecord;
41
+ }
42
+ export interface UnknownRecord {
43
+ kind: 'unknown';
44
+ raw: ProtocolRecord;
45
+ }
46
+ export type ParsedRecord = KnownRecord | UnknownRecord;
47
+ export declare function validateItem(body: JsonObject): Validation;
48
+ export declare function validateComment(body: JsonObject): Validation;
49
+ export declare function validateDelete(body: JsonObject): Validation;
50
+ export declare const itemDefinitions: readonly RecordDefinition[];
51
+ export declare function parseRecord(record: ProtocolRecord, definitions?: readonly RecordDefinition[]): ParsedRecord;
52
+ export declare function serializeRecord(parsed: ParsedRecord): ProtocolRecord;
package/dist/items.js ADDED
@@ -0,0 +1,45 @@
1
+ import { isId } from './ids.js';
2
+ const valid = () => ({ ok: true });
3
+ const invalid = (reason) => ({ ok: false, reason });
4
+ const string = (value) => typeof value === 'string';
5
+ const authored = (value) => value === 'human' || value === 'agent' || value === 'mixed';
6
+ const optional = (value, check) => value === undefined || check(value);
7
+ export function validateItem(body) {
8
+ if (!isId(body.id) || !string(body.itemType) || !body.itemType || !string(body.title) || !string(body.body) || !string(body.author) || !authored(body.authoredBy) || !string(body.created) || !Array.isArray(body.tags) || !body.tags.every(string))
9
+ return invalid('Invalid item fields');
10
+ if (!optional(body.links, v => Array.isArray(v) && v.every(link => link && typeof link === 'object' && isId(link.to) && string(link.rel))) || !optional(body.replaces, isId) || !optional(body.sharedFrom, string) || !optional(body.resourceKind, string))
11
+ return invalid('Invalid item extension fields');
12
+ return valid();
13
+ }
14
+ export function validateComment(body) {
15
+ if (!isId(body.id) || !isId(body.item) || !isId(body.onVersion) || !string(body.author) || !authored(body.authoredBy) || !string(body.at) || !optional(body.inReplyTo, isId) || !string(body.body))
16
+ return invalid('Invalid comment fields');
17
+ return valid();
18
+ }
19
+ export function validateDelete(body) { return isId(body.target) ? valid() : invalid('Invalid deletion target'); }
20
+ export const itemDefinitions = [
21
+ { name: 'item', version: 1, validate: validateItem },
22
+ { name: 'comment', version: 1, validate: validateComment },
23
+ { name: 'delete', version: 1, validate: validateDelete },
24
+ ];
25
+ export function parseRecord(record, definitions = itemDefinitions) {
26
+ if (typeof record?.type !== 'string' || !Number.isSafeInteger(record.typeVersion) || !record.body || typeof record.body !== 'object' || Array.isArray(record.body))
27
+ throw new Error('Invalid record');
28
+ const versions = definitions.filter(d => d.name === record.type);
29
+ let current = record;
30
+ let definition = versions.find(d => d.version === current.typeVersion);
31
+ if (!definition)
32
+ return { kind: 'unknown', raw: record };
33
+ while (definition) {
34
+ const result = definition.validate(current.body);
35
+ if (!result.ok)
36
+ throw new Error(result.reason);
37
+ const next = versions.find(d => d.version === current.typeVersion + 1);
38
+ if (!next?.upgrade)
39
+ break;
40
+ current = { ...current, typeVersion: next.version, body: next.upgrade(current.body) };
41
+ definition = next;
42
+ }
43
+ return { kind: 'known', record: current };
44
+ }
45
+ export function serializeRecord(parsed) { return parsed.kind === 'known' ? parsed.record : parsed.raw; }
package/dist/keys.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Identity, Recipient } from 'age-encryption';
2
+ export type AgeRecipient = string | Recipient;
3
+ export type AgeIdentity = string | CryptoKey | Identity;
4
+ export interface SigningIdentity {
5
+ publicKey: string;
6
+ privateKey: string;
7
+ }
8
+ export declare function createAgentIdentity(): Promise<{
9
+ privateKey: CryptoKey;
10
+ recipient: string;
11
+ }>;
12
+ export declare function createAgeIdentity(): Promise<{
13
+ identity: string;
14
+ recipient: string;
15
+ }>;
16
+ export declare function deriveRecipient(identity: AgeIdentity): Promise<string>;
17
+ export declare function createSigningIdentity(): Promise<SigningIdentity>;
18
+ export declare function importSigningKey(privateKey: string): Promise<CryptoKey>;
19
+ export declare function sealIdentity(identity: string, recipients: AgeRecipient[]): Promise<string>;
20
+ export declare function openIdentity(ciphertext: string, identities: AgeIdentity[]): Promise<string>;
package/dist/keys.js ADDED
@@ -0,0 +1,36 @@
1
+ import * as age from 'age-encryption';
2
+ import { asBuffer, decode, encode, text, utf8 } from './codec.js';
3
+ export async function createAgentIdentity() {
4
+ const pair = await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
5
+ return { privateKey: pair.privateKey, recipient: await age.identityToRecipient(pair.privateKey) };
6
+ }
7
+ export async function createAgeIdentity() {
8
+ const identity = await age.generateIdentity();
9
+ return { identity, recipient: await age.identityToRecipient(identity) };
10
+ }
11
+ export async function deriveRecipient(identity) {
12
+ if (typeof identity !== 'string' && !(identity instanceof CryptoKey))
13
+ throw new TypeError('A custom age identity cannot expose a standard recipient');
14
+ return age.identityToRecipient(identity);
15
+ }
16
+ export async function createSigningIdentity() {
17
+ const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
18
+ return { publicKey: encode(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), privateKey: encode(new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey))) };
19
+ }
20
+ export async function importSigningKey(privateKey) {
21
+ return crypto.subtle.importKey('pkcs8', asBuffer(decode(privateKey)), 'Ed25519', false, ['sign']);
22
+ }
23
+ export async function sealIdentity(identity, recipients) {
24
+ if (!recipients.length)
25
+ throw new Error('At least one recipient is required');
26
+ const encrypter = new age.Encrypter();
27
+ for (const recipient of recipients)
28
+ encrypter.addRecipient(recipient);
29
+ return encode(await encrypter.encrypt(utf8(identity)));
30
+ }
31
+ export async function openIdentity(ciphertext, identities) {
32
+ const decrypter = new age.Decrypter();
33
+ for (const identity of identities)
34
+ decrypter.addIdentity(identity);
35
+ return text(await decrypter.decrypt(decode(ciphertext)));
36
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { JsonObject, Validation } from './types.js';
2
+ export type Grant = 'members.manage';
3
+ export interface Member extends JsonObject {
4
+ id: string;
5
+ recipient: string;
6
+ signingKey: string;
7
+ kind: 'person' | 'agent';
8
+ name?: string;
9
+ scope?: 'read' | 'readwrite';
10
+ addedBy?: string;
11
+ expiresAt?: string;
12
+ support?: true;
13
+ }
14
+ export declare const validAgentName: (value: unknown) => value is string;
15
+ export interface LogEntry {
16
+ v: 1;
17
+ seq: number;
18
+ prev: string | null;
19
+ at: string;
20
+ actor: string;
21
+ type: string;
22
+ body: JsonObject;
23
+ sig: string;
24
+ }
25
+ export interface LogDefinition {
26
+ name: string;
27
+ fields: readonly string[];
28
+ validate(body: JsonObject): Validation;
29
+ apply?: (state: LogState, body: JsonObject, actor: string, holder: boolean) => Promise<EffectError | null>;
30
+ }
31
+ export interface DerivedMember {
32
+ member: Member;
33
+ grants: Grant[];
34
+ }
35
+ export interface LogState {
36
+ journey: string;
37
+ members: Record<string, DerivedMember>;
38
+ grants: Record<string, Grant[]>;
39
+ currentEpoch: number;
40
+ minClientVersion: string;
41
+ lastSeq: number;
42
+ lastHash: string | null;
43
+ }
44
+ export interface LogError {
45
+ code: 'invalid-entry' | 'broken-chain' | 'invalid-signature' | 'unauthorized' | 'last-holder' | 'client-too-old';
46
+ seq: number;
47
+ message: string;
48
+ }
49
+ export type LogResult = {
50
+ ok: true;
51
+ state: LogState;
52
+ } | {
53
+ ok: false;
54
+ error: LogError;
55
+ };
56
+ type EffectError = {
57
+ code: LogError['code'];
58
+ message: string;
59
+ };
60
+ export declare const logDefinitions: readonly LogDefinition[];
61
+ /** Canonical JSON: lexicographically sorted object keys, array order retained, UTF-8 for signing. */
62
+ export declare function canonical(value: unknown): string;
63
+ export declare function hashEntry(entry: LogEntry): Promise<string>;
64
+ export declare function recipientsHash(members: LogState['members']): Promise<string>;
65
+ export declare function signEntry(unsigned: Omit<LogEntry, 'sig'>, privateKey: CryptoKey): Promise<LogEntry>;
66
+ export declare function verifyLog(entries: readonly LogEntry[]): Promise<LogResult>;
67
+ export {};
package/dist/log.js ADDED
@@ -0,0 +1,201 @@
1
+ import { isId } from './ids.js';
2
+ import { asBuffer, decode, encode, utf8 } from './codec.js';
3
+ import { meetsMinClientVersion } from './versions.js';
4
+ export const validAgentName = (value) => typeof value === 'string' && value.length >= 1 && value.length <= 60 && value.trim() === value && !/[\x00-\x1f\x7f-\x9f]/.test(value);
5
+ const denied = (message) => ({ code: 'unauthorized', message });
6
+ function addMember(state, body, actor, holder) {
7
+ const member = body.member;
8
+ if (state.members[member.id])
9
+ return Promise.resolve({ code: 'invalid-entry', message: 'Duplicate member' });
10
+ if (member.kind === 'agent') {
11
+ if (member.addedBy !== actor || !state.members[member.addedBy] || body.grants.length)
12
+ return Promise.resolve(denied('An agent must belong to its acting person and hold no grants'));
13
+ }
14
+ else {
15
+ if (!holder)
16
+ return Promise.resolve(denied('Only a holder may add people'));
17
+ if (member.support && body.grants.length)
18
+ return Promise.resolve(denied('Support members cannot hold grants'));
19
+ }
20
+ state.members[member.id] = { member: { ...member }, grants: [...body.grants] };
21
+ state.grants[member.id] = [...body.grants];
22
+ return Promise.resolve(null);
23
+ }
24
+ function renameMember(state, body, actor, holder) {
25
+ const target = state.members[body.id]?.member;
26
+ if (!target || target.kind !== 'agent')
27
+ return Promise.resolve({ code: 'invalid-entry', message: 'Agent not found' });
28
+ if (!holder && target.addedBy !== actor)
29
+ return Promise.resolve(denied('Cannot rename another member’s agent'));
30
+ target.name = body.name;
31
+ return Promise.resolve(null);
32
+ }
33
+ function removeMember(state, body, actor, holder) {
34
+ const target = body.member;
35
+ const removed = state.members[target];
36
+ if (!removed)
37
+ return Promise.resolve({ code: 'invalid-entry', message: 'Member not found' });
38
+ if (!holder && target !== actor && removed.member.addedBy !== actor)
39
+ return Promise.resolve(denied('Cannot remove another member'));
40
+ delete state.members[target];
41
+ delete state.grants[target];
42
+ if (removed.member.kind === 'person')
43
+ for (const [id, value] of Object.entries(state.members))
44
+ if (value.member.addedBy === target) {
45
+ delete state.members[id];
46
+ delete state.grants[id];
47
+ }
48
+ return Promise.resolve(null);
49
+ }
50
+ function setGrant(add) {
51
+ return async (state, body, _actor, holder) => {
52
+ if (!holder)
53
+ return denied('Only a holder may change grants');
54
+ const target = body.member;
55
+ if (state.members[target]?.member.kind !== 'person')
56
+ return denied('Only people may hold grants');
57
+ if (state.members[target]?.member.support)
58
+ return denied('Support members cannot hold grants');
59
+ state.grants[target] = add ? ['members.manage'] : [];
60
+ state.members[target].grants = state.grants[target];
61
+ return null;
62
+ };
63
+ }
64
+ async function rotate(state, body, _actor, holder) {
65
+ if (!holder)
66
+ return denied('Only a holder may rotate keys');
67
+ if (body.epoch !== state.currentEpoch + 1 || body.recipientsHash !== await recipientsHash(state.members))
68
+ return { code: 'invalid-entry', message: 'Invalid key rotation recipients or epoch' };
69
+ state.currentEpoch++;
70
+ return null;
71
+ }
72
+ async function setMinimum(state, body, _actor, holder) {
73
+ if (!holder)
74
+ return denied('Only a holder may update minimum client version');
75
+ if (!meetsMinClientVersion(body.version, state.minClientVersion))
76
+ return { code: 'invalid-entry', message: 'Minimum client version cannot decrease' };
77
+ state.minClientVersion = body.version;
78
+ return null;
79
+ }
80
+ const ok = { ok: true };
81
+ const fail = (reason) => ({ ok: false, reason });
82
+ const object = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
83
+ const str = (value) => typeof value === 'string' && value.length > 0;
84
+ const shape = (body, required, allowed = required) => required.every(k => Object.hasOwn(body, k)) && Object.keys(body).every(k => allowed.includes(k));
85
+ function validMember(value) {
86
+ if (!object(value) || !shape(value, ['id', 'recipient', 'signingKey', 'kind'], ['id', 'recipient', 'signingKey', 'kind', 'name', 'scope', 'addedBy', 'expiresAt', 'support']))
87
+ return false;
88
+ if (!isId(value.id) || !str(value.recipient) || !str(value.signingKey))
89
+ return false;
90
+ return value.kind === 'person' ? value.name === undefined && value.addedBy === undefined && (value.support === true ? value.scope === 'read' && typeof value.expiresAt === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value.expiresAt) && Number.isFinite(Date.parse(value.expiresAt)) : value.support === undefined && value.scope === undefined && (value.expiresAt === undefined || str(value.expiresAt))) : value.kind === 'agent' && value.support === undefined && (value.name === undefined || validAgentName(value.name)) && (value.scope === 'read' || value.scope === 'readwrite') && str(value.addedBy) && (value.expiresAt === undefined || str(value.expiresAt));
91
+ }
92
+ const grants = (value) => Array.isArray(value) && value.every(v => v === 'members.manage') && new Set(value).size === value.length;
93
+ const memberBody = (body) => shape(body, ['member', 'grants', 'kind']) && validMember(body.member) && body.kind === body.member.kind && grants(body.grants) ? ok : fail('Invalid member.add');
94
+ export const logDefinitions = [
95
+ { name: 'genesis', fields: ['journey', 'name', 'creator', 'grants', 'mode', 'visibility', 'minClientVersion', 'description', 'journeyKind'], validate: b => shape(b, ['journey', 'name', 'creator', 'grants', 'mode', 'visibility', 'minClientVersion'], ['journey', 'name', 'creator', 'grants', 'mode', 'visibility', 'minClientVersion', 'description', 'journeyKind']) && isId(b.journey) && str(b.name) && validMember(b.creator) && b.creator.kind === 'person' && grants(b.grants) && b.grants.includes('members.manage') && b.mode === 'sealed' && b.visibility === 'private' && str(b.minClientVersion) && (b.description === undefined || typeof b.description === 'string' && b.description.length <= 2000) && (b.journeyKind === undefined || b.journeyKind === 'individual' || b.journeyKind === 'team') ? ok : fail('Invalid genesis') },
96
+ { name: 'member.add', fields: ['member', 'grants', 'kind'], validate: memberBody, apply: addMember },
97
+ { name: 'member.rename', fields: ['id', 'name'], validate: b => shape(b, ['id', 'name']) && isId(b.id) && validAgentName(b.name) ? ok : fail('Invalid member.rename'), apply: renameMember },
98
+ { name: 'member.remove', fields: ['member'], validate: b => shape(b, ['member']) && isId(b.member) ? ok : fail('Invalid member.remove'), apply: removeMember },
99
+ { name: 'grant.add', fields: ['member', 'grant'], validate: b => shape(b, ['member', 'grant']) && isId(b.member) && b.grant === 'members.manage' ? ok : fail('Invalid grant.add'), apply: setGrant(true) },
100
+ { name: 'grant.remove', fields: ['member', 'grant'], validate: b => shape(b, ['member', 'grant']) && isId(b.member) && b.grant === 'members.manage' ? ok : fail('Invalid grant.remove'), apply: setGrant(false) },
101
+ { name: 'key.rotate', fields: ['epoch', 'recipientsHash'], validate: b => shape(b, ['epoch', 'recipientsHash']) && Number.isSafeInteger(b.epoch) && str(b.recipientsHash) ? ok : fail('Invalid key.rotate'), apply: rotate },
102
+ { name: 'client.minVersion', fields: ['version'], validate: b => shape(b, ['version']) && str(b.version) ? ok : fail('Invalid client.minVersion'), apply: setMinimum },
103
+ ];
104
+ /** Canonical JSON: lexicographically sorted object keys, array order retained, UTF-8 for signing. */
105
+ export function canonical(value) {
106
+ if (value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)))
107
+ return JSON.stringify(value);
108
+ if (Array.isArray(value))
109
+ return '[' + value.map(canonical).join(',') + ']';
110
+ if (object(value))
111
+ return '{' + Object.keys(value).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
112
+ throw new TypeError('Non-JSON value in signed log');
113
+ }
114
+ export async function hashEntry(entry) {
115
+ return encode(new Uint8Array(await crypto.subtle.digest('SHA-256', asBuffer(utf8(canonical(entry))))));
116
+ }
117
+ export async function recipientsHash(members) {
118
+ const sorted = Object.values(members).map(({ member }) => [member.id, member.recipient]).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
119
+ return encode(new Uint8Array(await crypto.subtle.digest('SHA-256', asBuffer(utf8(canonical(sorted))))));
120
+ }
121
+ function copyMember(member) {
122
+ const result = { id: member.id, recipient: member.recipient, signingKey: member.signingKey, kind: member.kind };
123
+ for (const field of ['name', 'scope', 'addedBy', 'expiresAt', 'support'])
124
+ if (Object.hasOwn(member, field))
125
+ result[field] = member[field];
126
+ return result;
127
+ }
128
+ export async function signEntry(unsigned, privateKey) {
129
+ const definition = logDefinitions.find(d => d.name === unsigned.type);
130
+ if (!definition)
131
+ throw new Error('Cannot sign an unknown membership entry');
132
+ const body = {};
133
+ for (const field of definition.fields)
134
+ if (Object.hasOwn(unsigned.body, field)) {
135
+ const value = unsigned.body[field];
136
+ body[field] = (field === 'creator' || field === 'member' && object(value)) && object(value) ? copyMember(value) : field === 'grants' && Array.isArray(value) ? [...value] : value;
137
+ }
138
+ const validation = definition.validate(body);
139
+ if (!validation.ok)
140
+ throw new Error(validation.reason);
141
+ const message = { v: unsigned.v, seq: unsigned.seq, prev: unsigned.prev, at: unsigned.at, actor: unsigned.actor, type: unsigned.type, body };
142
+ const sig = encode(new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, asBuffer(utf8(canonical(message))))));
143
+ return { ...message, sig };
144
+ }
145
+ function error(code, seq, message) { return { ok: false, error: { code, seq, message } }; }
146
+ export async function verifyLog(entries) {
147
+ if (!entries.length)
148
+ return error('invalid-entry', 0, 'Missing genesis');
149
+ let state;
150
+ for (let index = 0; index < entries.length; index++) {
151
+ const entry = entries[index];
152
+ if (!object(entry) || !shape(entry, ['v', 'seq', 'prev', 'at', 'actor', 'type', 'body', 'sig']) || entry.v !== 1 || !Number.isSafeInteger(entry.seq) || entry.seq !== index || entry.prev !== (state?.lastHash ?? null))
153
+ return error('broken-chain', index, 'Sequence or previous hash mismatch');
154
+ if (!str(entry.at) || !str(entry.actor) || !str(entry.type) || !object(entry.body) || !str(entry.sig))
155
+ return error('invalid-entry', index, 'Invalid entry fields');
156
+ if ((index === 0) !== (entry.type === 'genesis'))
157
+ return error('invalid-entry', index, 'Genesis must be the first and only genesis');
158
+ const definition = logDefinitions.find(d => d.name === entry.type);
159
+ if (index === 0) {
160
+ const validation = definition.validate(entry.body);
161
+ if (!validation.ok)
162
+ return error('invalid-entry', index, validation.reason);
163
+ }
164
+ const signer = index === 0 ? entry.body.creator : state?.members[entry.actor]?.member;
165
+ if (!signer || (index === 0 && signer.id !== entry.actor))
166
+ return error('unauthorized', index, 'Actor is not a current member');
167
+ try {
168
+ const publicKey = await crypto.subtle.importKey('raw', asBuffer(decode(signer.signingKey)), 'Ed25519', false, ['verify']);
169
+ const { sig, ...unsigned } = entry;
170
+ if (!await crypto.subtle.verify('Ed25519', publicKey, asBuffer(decode(sig)), asBuffer(utf8(canonical(unsigned)))))
171
+ return error('invalid-signature', index, 'Entry signature mismatch');
172
+ }
173
+ catch {
174
+ return error('invalid-signature', index, 'Invalid signature or signing key');
175
+ }
176
+ if (!definition)
177
+ return error('client-too-old', index, 'Unknown membership entry type: ' + entry.type);
178
+ if (index !== 0) {
179
+ const validation = definition.validate(entry.body);
180
+ if (!validation.ok)
181
+ return error('invalid-entry', index, validation.reason);
182
+ }
183
+ if (index === 0) {
184
+ const member = entry.body.creator;
185
+ state = { journey: entry.body.journey, members: { [member.id]: { member, grants: ['members.manage'] } }, grants: { [member.id]: ['members.manage'] }, currentEpoch: 1, minClientVersion: entry.body.minClientVersion, lastSeq: 0, lastHash: await hashEntry(entry) };
186
+ continue;
187
+ }
188
+ const current = state;
189
+ const holder = signer.kind === 'person' && current.grants[entry.actor]?.includes('members.manage');
190
+ if (signer.kind !== 'person')
191
+ return error('unauthorized', index, 'Agents cannot sign membership changes');
192
+ const failure = await definition.apply?.(current, entry.body, entry.actor, Boolean(holder));
193
+ if (failure)
194
+ return error(failure.code, index, failure.message);
195
+ if (!Object.entries(current.members).some(([id, value]) => value.member.kind === 'person' && current.grants[id]?.includes('members.manage')))
196
+ return error('last-holder', index, 'At least one person must retain members.manage');
197
+ current.lastSeq = index;
198
+ current.lastHash = await hashEntry(entry);
199
+ }
200
+ return { ok: true, state: state };
201
+ }
@@ -0,0 +1,7 @@
1
+ import type { LogEntry, LogState } from './log.js';
2
+ import type { JourneyKey, KeyWrap } from './teamKey.js';
3
+ export declare function removeAndRotate(state: LogState, target: string, actor: string, signingKey: CryptoKey): Promise<{
4
+ entries: [LogEntry, LogEntry];
5
+ key: JourneyKey;
6
+ wraps: KeyWrap[];
7
+ }>;
@@ -0,0 +1,20 @@
1
+ import { hashEntry, recipientsHash, signEntry } from './log.js';
2
+ import { generateJourneyKey, wrapJourneyKey } from './teamKey.js';
3
+ export async function removeAndRotate(state, target, actor, signingKey) {
4
+ if (state.members[actor]?.member.kind !== 'person' || !state.grants[actor]?.includes('members.manage'))
5
+ throw new Error('A person with members.manage must act');
6
+ if (target === actor)
7
+ throw new Error('A remaining acting holder must complete rotation after self-removal');
8
+ const removed = state.members[target];
9
+ if (!removed)
10
+ throw new Error('Member not found');
11
+ const remaining = Object.fromEntries(Object.entries(state.members).filter(([id, value]) => id !== target && (removed.member.kind !== 'person' || value.member.addedBy !== target)));
12
+ if (!Object.entries(remaining).some(([id, value]) => value.member.kind === 'person' && state.grants[id]?.includes('members.manage')))
13
+ throw new Error('Cannot remove last holder');
14
+ const at = new Date().toISOString();
15
+ const first = await signEntry({ v: 1, seq: state.lastSeq + 1, prev: state.lastHash, at, actor, type: 'member.remove', body: { member: target } }, signingKey);
16
+ const key = generateJourneyKey(state.currentEpoch + 1);
17
+ const wraps = await wrapJourneyKey(key, Object.values(remaining).map(({ member }) => ({ id: member.id, recipient: member.recipient })));
18
+ const second = await signEntry({ v: 1, seq: first.seq + 1, prev: await hashEntry(first), at, actor, type: 'key.rotate', body: { epoch: key.epoch, recipientsHash: await recipientsHash(remaining) } }, signingKey);
19
+ return { entries: [first, second], key, wraps };
20
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgeIdentity, AgeRecipient } from './keys.js';
2
+ export interface JourneyKey {
3
+ epoch: number;
4
+ key: Uint8Array;
5
+ }
6
+ export interface KeyWrap {
7
+ epoch: number;
8
+ recipient: string;
9
+ ciphertext: string;
10
+ }
11
+ export declare function generateJourneyKey(epoch?: number): JourneyKey;
12
+ export declare function wrapJourneyKey(key: JourneyKey, recipients: {
13
+ id: string;
14
+ recipient: AgeRecipient;
15
+ }[]): Promise<KeyWrap[]>;
16
+ export declare function unwrapJourneyKey(wrap: KeyWrap, identity: AgeIdentity): Promise<JourneyKey>;
17
+ export declare function rotateJourneyKey(current: JourneyKey, recipients: {
18
+ id: string;
19
+ recipient: AgeRecipient;
20
+ }[]): Promise<{
21
+ key: JourneyKey;
22
+ wraps: KeyWrap[];
23
+ }>;
@@ -0,0 +1,24 @@
1
+ import { decode, encode } from './codec.js';
2
+ import { openIdentity, sealIdentity } from './keys.js';
3
+ export function generateJourneyKey(epoch = 1) {
4
+ if (!Number.isSafeInteger(epoch) || epoch < 1)
5
+ throw new Error('Invalid epoch');
6
+ return { epoch, key: crypto.getRandomValues(new Uint8Array(32)) };
7
+ }
8
+ export async function wrapJourneyKey(key, recipients) {
9
+ if (key.key.length !== 32)
10
+ throw new Error('Journey key must be 256 bits');
11
+ if (new Set(recipients.map(r => r.id)).size !== recipients.length)
12
+ throw new Error('Duplicate recipient');
13
+ return Promise.all(recipients.map(async ({ id, recipient }) => ({ epoch: key.epoch, recipient: id, ciphertext: await sealIdentity(encode(key.key), [recipient]) })));
14
+ }
15
+ export async function unwrapJourneyKey(wrap, identity) {
16
+ const key = decode(await openIdentity(wrap.ciphertext, [identity]));
17
+ if (key.length !== 32)
18
+ throw new Error('Invalid journey key');
19
+ return { epoch: wrap.epoch, key };
20
+ }
21
+ export async function rotateJourneyKey(current, recipients) {
22
+ const key = generateJourneyKey(current.epoch + 1);
23
+ return { key, wraps: await wrapJourneyKey(key, recipients) };
24
+ }
@@ -0,0 +1,14 @@
1
+ import type { AgeIdentity, AgeRecipient } from './keys.js';
2
+ import type { Envelope } from './envelope.js';
3
+ import type { LogEntry, LogState } from './log.js';
4
+ import type { KeyWrap } from './teamKey.js';
5
+ export interface JourneyArchive {
6
+ log: LogEntry[];
7
+ envelopes: Envelope[];
8
+ wraps: KeyWrap[];
9
+ }
10
+ export declare function exportJourney(log: LogEntry[], envelopes: Envelope[], wraps: KeyWrap[], recipients: AgeRecipient[]): Promise<string>;
11
+ export declare function importJourney(ciphertext: string, identities: AgeIdentity[]): Promise<{
12
+ archive: JourneyArchive;
13
+ state: LogState;
14
+ }>;
@@ -0,0 +1,39 @@
1
+ import { openIdentity, sealIdentity } from './keys.js';
2
+ import { open } from './envelope.js';
3
+ import { verifyLog } from './log.js';
4
+ import { unwrapJourneyKey } from './teamKey.js';
5
+ import { parseRecord } from './items.js';
6
+ export async function exportJourney(log, envelopes, wraps, recipients) {
7
+ return sealIdentity(JSON.stringify({ log, envelopes, wraps }), recipients);
8
+ }
9
+ export async function importJourney(ciphertext, identities) {
10
+ const parsed = JSON.parse(await openIdentity(ciphertext, identities));
11
+ if (!parsed || typeof parsed !== 'object' || !('log' in parsed) || !Array.isArray(parsed.log) || !('envelopes' in parsed) || !Array.isArray(parsed.envelopes) || !('wraps' in parsed) || !Array.isArray(parsed.wraps))
12
+ throw new Error('Invalid journey archive');
13
+ // Untrusted archive: verify its signed history before using its wraps or contents.
14
+ const archive = { log: parsed.log, envelopes: parsed.envelopes, wraps: parsed.wraps };
15
+ const verified = await verifyLog(archive.log);
16
+ if (!verified.ok)
17
+ throw new Error(verified.error.code + ': ' + verified.error.message);
18
+ for (const envelope of archive.envelopes) {
19
+ if (!envelope?.outside || envelope.outside.journey !== verified.state.journey || envelope.outside.epoch > verified.state.currentEpoch || !Number.isSafeInteger(envelope.outside.epoch))
20
+ throw new Error('Invalid archive envelope metadata');
21
+ let opened = false;
22
+ for (const wrap of archive.wraps.filter(w => w.epoch === envelope.outside.epoch)) {
23
+ for (const identity of identities) {
24
+ try {
25
+ const key = await unwrapJourneyKey(wrap, identity);
26
+ parseRecord(await open(envelope, key));
27
+ opened = true;
28
+ break;
29
+ }
30
+ catch { /* try another permitted identity / wrap */ }
31
+ }
32
+ if (opened)
33
+ break;
34
+ }
35
+ if (!opened)
36
+ throw new Error('No valid key or ciphertext for archive envelope');
37
+ }
38
+ return { archive, state: verified.state };
39
+ }
@@ -0,0 +1,16 @@
1
+ /** JSON values preserve extensions at each protocol boundary. */
2
+ export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
3
+ export type JsonObject = {
4
+ [key: string]: JsonValue | undefined;
5
+ };
6
+ export type ProtocolRecord = {
7
+ type: string;
8
+ typeVersion: number;
9
+ body: JsonObject;
10
+ } & JsonObject;
11
+ export type Validation = {
12
+ ok: true;
13
+ } | {
14
+ ok: false;
15
+ reason: string;
16
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare const PROTOCOL_VERSION: 1;
2
+ export declare const CLIENT_VERSION: "0.1.0";
3
+ export declare function meetsMinClientVersion(client: string, minimum: string): boolean;
@@ -0,0 +1,17 @@
1
+ export const PROTOCOL_VERSION = 1;
2
+ export const CLIENT_VERSION = '0.1.0';
3
+ function parts(value) {
4
+ if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value))
5
+ throw new Error('Invalid client version');
6
+ const numbers = value.split('.').map(Number);
7
+ if (numbers.some(n => !Number.isSafeInteger(n)))
8
+ throw new Error('Invalid client version');
9
+ return numbers;
10
+ }
11
+ export function meetsMinClientVersion(client, minimum) {
12
+ const a = parts(client), b = parts(minimum);
13
+ for (let i = 0; i < 3; i++)
14
+ if (a[i] !== b[i])
15
+ return a[i] > b[i];
16
+ return true;
17
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@ai-wayfinding/core",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=22"
7
+ },
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "typecheck": "tsc --noEmit",
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "test:workers": "vitest run --config vitest.workers.config.ts",
23
+ "test:browser": "vitest run --config vitest.browser.config.ts"
24
+ },
25
+ "dependencies": {
26
+ "age-encryption": "^0.3.1"
27
+ },
28
+ "devDependencies": {
29
+ "@cloudflare/vitest-pool-workers": "^0.22.0",
30
+ "@vitest/browser-playwright": "^4.1.11",
31
+ "playwright": "^1.63.0",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^4.1.0"
34
+ },
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/AI-Wayfinding/journey.git",
39
+ "directory": "packages/core"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }