Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
Expand Down Expand Up @@ -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**:
Expand Down
51 changes: 51 additions & 0 deletions packages/adapter-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import { dirname, join } from 'path';
import { existsSync } from 'fs';
import type {
StackAdapter,
StackRecord,
Expand Down Expand Up @@ -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<string>` (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<string>);
/** IANA timezone string. Consulted only on the initialize path. */
timezone?: string;
/** Bypass the storage-ownership lock check. See LocalOpenOptions.force. */
force?: boolean;
};

// -------------------------------------------------------
// LocalAdapter
// -------------------------------------------------------
Expand Down Expand Up @@ -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<LocalAdapter> {
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<NativeTokenStore> {
if (!this.tokenStore) {
this.tokenStore = await NativeTokenStore.open({
Expand Down
54 changes: 53 additions & 1 deletion packages/adapter-local/tests/local.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
// -------------------------------------------------------
Expand Down
29 changes: 17 additions & 12 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
Expand Down Expand Up @@ -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:
Expand Down
Loading