diff --git a/README.md b/README.md index 7d791d6..78d30d6 100644 --- a/README.md +++ b/README.md @@ -96,24 +96,27 @@ Planned: ## Quick start ```ts -import { Stack, generateDidKeypair } from '@haverstack/core'; +import { Stack, generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core'; import { LocalAdapter } from '@haverstack/adapter-local'; +import { writeFile } from 'node:fs/promises'; -// First run — generate an identity keypair. `did` is a "did:key:z6Mk..." -// string derived from the public key — that's your entityId. Persist -// `privateKey` yourself somewhere safe (OS keychain, encrypted file, ...); -// the stack never stores it, only the public identity. -const { did, privateKey } = await generateDidKeypair(); +const dbPath = './my-stack.db'; +const keyPath = './my-stack.key.json'; // see "Key custody" below for where this really belongs -const adapter = await LocalAdapter.initialize({ - path: './my-stack.db', - entityId: did, +// First run: neither file exists yet, so this generates an identity +// keypair and persists the private key before initializing. Every run +// after that: the db exists, so this just opens it — the entityId +// function below is never called, so no throwaway keypair is minted. +const adapter = await LocalAdapter.openOrInitialize({ + path: dbPath, timezone: 'America/New_York', + entityId: async () => { + const { did, privateKey } = await generateDidKeypair(); + await writeFile(keyPath, JSON.stringify(await exportDidPrivateKeyJwk(privateKey))); + return did; + }, }); -// Subsequent runs — open the existing stack -// const adapter = await LocalAdapter.open({ path: './my-stack.db' }); - // ownerProfile creates your own _entity profile record on first run — // safe to keep passing on every open, it's a no-op once the record exists. const stack = await Stack.create(adapter, { ownerProfile: { name: 'Jane Smith' } }); @@ -167,6 +170,18 @@ The `_entity` record type is a **local profile** about a DID, not the identity i See [Identity](./docs/spec/identity.md) in the spec for the full model, including authentication (challenge–response, not a shared secret) and what's deliberately deferred (key rotation). +#### Key custody + +`generateDidKeypair()` returns a `privateKey`; nothing in `@haverstack/core` or any adapter stores it — only the public `did` travels with stack data. Where the key lives, and how it survives a reinstall, is entirely on you. Some starting points: + +- **Node / server** — write the JWK (`exportDidPrivateKeyJwk()`) to a file outside version control, ideally encrypted at rest (e.g. via your OS keychain, or a secrets manager if the process runs on infrastructure you don't hold in your hands). A bare unencrypted file on disk, permissioned `0600`, is the honest floor for local dev. +- **Desktop (Electron, Tauri, ...)** — use the platform keychain binding your framework exposes (e.g. Electron's `safeStorage`, or the OS keychain directly) rather than a plain file; these run in a context with real users and real disks that get imaged and backed up by other software. +- **Browser** — store the `CryptoKey` object itself in IndexedDB instead of exporting to JWK — `generateDidKeypair()` returns an extractable key, but a browser app never has to extract it. Structured-clone support means IndexedDB can hold the `CryptoKey` directly (`idb.put('keys', privateKey, 'owner')`), so the raw key material never touches JS-readable memory as a string. + +On every path, reconstruct the key with `importDidPrivateKeyJwk()` (or read the `CryptoKey` straight back out of IndexedDB) and hand it to `signWithDid()` / `buildAuthChallengePayload()` when authenticating to a server — see the "Authentication: challenge–response" section of [Identity](./docs/spec/identity.md) in the spec. + +**The asymmetry that makes this matter:** losing the key doesn't break anything local — nothing in the stack ever asks for it again, `openOrInitialize()`/`open()` only need the `did`. But you can never again authenticate as that identity to any server, because there's no recovery path — `did:key` identity _is_ the key (see [Deferred: key rotation](./docs/spec/identity.md#deferred-key-rotation)). An early "didn't bother persisting it" decision is invisible until the day you want to serve or share the stack, and by then it's permanent. Persist it from the first run, even if you don't yet know why you'd need it. + ### Types Types define the schema for a record's content. They are identified by a **namespaced, versioned string**: diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index f9a9877..846c4f2 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -14,6 +14,7 @@ */ import { dirname, join } from 'path'; +import { existsSync } from 'fs'; import type { StackAdapter, StackRecord, @@ -79,6 +80,28 @@ export type LocalOpenOptions = { force?: boolean; }; +export type LocalOpenOrInitializeOptions = { + /** Absolute path to the .db file — opened if it exists, initialized if not. */ + path: string; + /** + * Entity ID of the stack owner, consulted only on the initialize path + * (the file doesn't exist yet). Pass a plain DID string, or a lazy + * `() => string | Promise` (e.g. wrapping `generateDidKeypair()`) + * so keypair generation only happens when a stack is actually being + * created — the provider is never invoked when the file already exists. + * + * A plain string is also asserted against the existing stack's owner on + * the open path, to catch silent config divergence. A lazy provider is + * *not* checked there — invoking it just to compare would defeat the + * point of making it lazy. + */ + entityId: string | (() => string | Promise); + /** IANA timezone string. Consulted only on the initialize path. */ + timezone?: string; + /** Bypass the storage-ownership lock check. See LocalOpenOptions.force. */ + force?: boolean; +}; + // ------------------------------------------------------- // LocalAdapter // ------------------------------------------------------- @@ -125,6 +148,34 @@ export class LocalAdapter implements StackAdapter { return new LocalAdapter(record, blob, opts.path, opts.force); } + /** + * Open the stack at `path` if it already exists, or initialize a new one + * there if it doesn't — the first-run choreography (does the db exist? + * open : generate identity, initialize) that every adopter otherwise + * has to write by hand. See LocalOpenOrInitializeOptions for how + * `entityId` is used differently on each path. + */ + static async openOrInitialize(opts: LocalOpenOrInitializeOptions): Promise { + if (existsSync(opts.path)) { + const adapter = await LocalAdapter.open({ path: opts.path, force: opts.force }); + if (typeof opts.entityId === 'string' && opts.entityId !== adapter.ownerEntityId) { + throw new Error( + `Cannot open: stack at "${opts.path}" is owned by "${adapter.ownerEntityId}", ` + + `but openOrInitialize() was called with entityId "${opts.entityId}".`, + ); + } + return adapter; + } + + const entityId = typeof opts.entityId === 'function' ? await opts.entityId() : opts.entityId; + return LocalAdapter.initialize({ + path: opts.path, + entityId, + timezone: opts.timezone, + force: opts.force, + }); + } + private async getTokenStore(): Promise { if (!this.tokenStore) { this.tokenStore = await NativeTokenStore.open({ diff --git a/packages/adapter-local/tests/local.test.ts b/packages/adapter-local/tests/local.test.ts index c0b15e7..faba7e4 100644 --- a/packages/adapter-local/tests/local.test.ts +++ b/packages/adapter-local/tests/local.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdirSync, rmSync, existsSync, readdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -96,6 +96,58 @@ describe('open', () => { }); }); +describe('openOrInitialize', () => { + test('initializes a new database when none exists', async () => { + const adapter = await LocalAdapter.openOrInitialize({ path: dbPath, entityId: 'owner-abc' }); + expect(existsSync(dbPath)).toBe(true); + expect(adapter.ownerEntityId).toBe('owner-abc'); + }); + + test('opens an existing database instead of re-initializing', async () => { + await initAdapter({ entityId: 'owner-abc' }); + const record = makeRecord({ id: 'existing' }); + await (await LocalAdapter.open({ path: dbPath })).createRecord(record); + + const adapter = await LocalAdapter.openOrInitialize({ path: dbPath, entityId: 'owner-abc' }); + expect(adapter.ownerEntityId).toBe('owner-abc'); + expect(await adapter.getRecord('existing')).not.toBeNull(); + }); + + test('does not invoke a lazy entityId provider on the open path', async () => { + await initAdapter({ entityId: 'owner-abc' }); + const provider = vi.fn(() => 'should-not-be-called'); + + const adapter = await LocalAdapter.openOrInitialize({ path: dbPath, entityId: provider }); + + expect(adapter.ownerEntityId).toBe('owner-abc'); + expect(provider).not.toHaveBeenCalled(); + }); + + test('invokes a lazy entityId provider on the initialize path, sync or async', async () => { + const adapter = await LocalAdapter.openOrInitialize({ + path: dbPath, + entityId: async () => 'generated-owner', + }); + expect(adapter.ownerEntityId).toBe('generated-owner'); + }); + + test('throws if a plain-string entityId does not match the existing owner', async () => { + await initAdapter({ entityId: 'owner-abc' }); + await expect( + LocalAdapter.openOrInitialize({ path: dbPath, entityId: 'owner-xyz' }), + ).rejects.toThrow(/owned by "owner-abc"/); + }); + + test('passes timezone through on the initialize path only', async () => { + const adapter = await LocalAdapter.openOrInitialize({ + path: dbPath, + entityId: 'owner-abc', + timezone: 'Europe/London', + }); + expect(adapter.timezone).toBe('Europe/London'); + }); +}); + // ------------------------------------------------------- // Blob operations through LocalAdapter // ------------------------------------------------------- diff --git a/packages/core/README.md b/packages/core/README.md index 562ed9b..9d7346f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,24 +19,27 @@ You'll also need a storage adapter: ## Quick start ```ts -import { Stack, generateDidKeypair } from '@haverstack/core'; +import { Stack, generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core'; import { LocalAdapter } from '@haverstack/adapter-local'; +import { writeFile } from 'node:fs/promises'; -// First run — generate an identity keypair. `did` is a "did:key:z6Mk..." -// string derived from the public key — that's your entityId. Persist -// `privateKey` yourself somewhere safe (OS keychain, encrypted file, ...); -// the stack never stores it, only the public identity. -const { did, privateKey } = await generateDidKeypair(); +const dbPath = './my-stack.db'; +const keyPath = './my-stack.key.json'; // see "Key custody" below for where this really belongs -const adapter = await LocalAdapter.initialize({ - path: './my-stack.db', - entityId: did, +// First run: neither file exists yet, so this generates an identity +// keypair and persists the private key before initializing. Every run +// after that: the db exists, so this just opens it — the entityId +// function below is never called, so no throwaway keypair is minted. +const adapter = await LocalAdapter.openOrInitialize({ + path: dbPath, timezone: 'America/New_York', + entityId: async () => { + const { did, privateKey } = await generateDidKeypair(); + await writeFile(keyPath, JSON.stringify(await exportDidPrivateKeyJwk(privateKey))); + return did; + }, }); -// Subsequent runs — open the existing stack -// const adapter = await LocalAdapter.open({ path: './my-stack.db' }); - // ownerProfile creates your own _entity profile record on first run — // safe to keep passing on every open, it's a no-op once the record exists. const stack = await Stack.create(adapter, { ownerProfile: { name: 'Jane Smith' } }); @@ -84,6 +87,8 @@ The fundamental unit of data. Every record has: `entityId` is a DID string (e.g. `did:key:z6Mk...`) — a keypair, not a name issued by any provider. `generateDidKeypair()` mints the mandatory floor method, `did:key`. `_entity` records are local profile cards _about_ a DID (`{ did, name, handle? }`), not the identity itself — the petname pattern. See [Identity](https://github.com/haverstack/core/blob/main/docs/spec/identity.md) in the spec. +**Key custody.** Nothing in `@haverstack/core` or any adapter stores the `privateKey` — only the public `did` travels with stack data. Persisting it (JWK via `exportDidPrivateKeyJwk()`/`importDidPrivateKeyJwk()`, or the `CryptoKey` itself in a browser's IndexedDB) is on you. Losing it doesn't break anything local — but you can never again authenticate as that identity to a server, since `did:key` identity _is_ the key. See [Key custody](https://github.com/haverstack/core#key-custody) in the main README for per-platform recipes. + ### Types Types define the schema for a record's content. They are identified by a namespaced, versioned string: