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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ These mirror `.github/workflows/ci.yml` exactly, so a clean local run means a gr

**`pnpm run typecheck` requires `pnpm run build` first.** Packages import each other through their published entry points (`@haverstack/core`, `@haverstack/sqlite-shared`, …), which resolve to `dist/*.d.ts`. Without a build, those imports fail with `TS2307: Cannot find module '@haverstack/core'`, and every type that came through them degrades to `unknown` — producing a cascade of unrelated-looking errors (`TS18046: 'err' is of type 'unknown'`) in code that is perfectly fine. CI encodes this: its `typecheck` job declares `needs: build`. If you see a wall of `unknown` errors, check whether the _first_ error in the list is a missing module, and build before doing anything else.

**`pnpm test` does not need a build.** Each package's `vitest.config.ts` aliases `@haverstack/*` to the other package's `src/index.ts`, so tests always run against current source rather than a stale `dist`. A new cross-package dependency needs a matching alias added to the dependent package's vitest config, or its tests will resolve to `dist` and behave inconsistently.
**`pnpm test` does not need a build.** Each package's `vitest.config.ts` aliases `@haverstack/*` specifiers to the other package's `src/` files, so tests always run against current source rather than a stale `dist`. A new cross-package dependency needs a matching alias added to the dependent package's vitest config, or its tests will resolve to `dist` and behave inconsistently. Subpath specifiers (`@haverstack/core/testing`, `@haverstack/core/did`, `@haverstack/core/wire`, `@haverstack/core/adapter`) each need their own alias entry pointing at the matching entry-point file, listed before the bare `@haverstack/core` entry so the more specific match wins.

---

Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ On the app's side, connecting is the keypair plus a URL. `APIAdapter` performs t

```ts
import { APIAdapter } from '@haverstack/adapter-api';
import { didCredentialFromKeypair } from '@haverstack/core';
import { didCredentialFromKeypair } from '@haverstack/core/wire';

const adapter = await APIAdapter.open({
url: 'https://stack.example.com',
Expand Down Expand Up @@ -97,7 +97,8 @@ Planned:
## Quick start

```ts
import { Stack, generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core';
import { Stack } from '@haverstack/core';
import { generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core/did';
import { LocalAdapter } from '@haverstack/adapter-local';
import { writeFile } from 'node:fs/promises';

Expand Down Expand Up @@ -243,7 +244,7 @@ The adapter interface is split into `StackRecordAdapter` (structured records) an
| `adapter-api` | full | Hosted/shared stacks via HTTP |
| `adapter-json` | full | Portable JSON files _(planned)_ |

Use `combineAdapters({ record, blob })` from `@haverstack/core` to compose a record adapter with a different blob backend — for example, `NativeSQLiteRecordAdapter` with a future `S3BlobAdapter`. `adapter-local` wraps this pattern for the common case.
Use `combineAdapters({ record, blob })` from `@haverstack/core/adapter` to compose a record adapter with a different blob backend — for example, `NativeSQLiteRecordAdapter` with a future `S3BlobAdapter`. `adapter-local` wraps this pattern for the common case.

---

Expand Down Expand Up @@ -277,15 +278,21 @@ docs/
packages/
core/ # @haverstack/core
src/
index.ts # Public exports
index.ts # Root public exports — Stack, data types, general-purpose utilities
did-entry.ts # ./did public exports — keygen, custody, signing (did:key)
wire-entry.ts # ./wire public exports — auth handshake, attachment-download policy
adapter-entry.ts # ./adapter public exports — the interfaces a storage adapter implements
types.ts # All type definitions (StackRecordAdapter, StackBlobAdapter, StackAdapter, …)
stack.ts # Stack class
combine.ts # combineAdapters() — compose record + blob adapters
access.ts # Permission and grant checking
id.ts # Crockford base-32 ID generation
schema.ts # Schema hashing and type compatibility
validate.ts # Content validation
testing.ts # MemoryAdapter test helper (@haverstack/core/testing)
did.ts # did:key implementation
auth.ts # Auth handshake implementation
attachment-download.ts # Attachment download content-type resolution
testing.ts # MemoryAdapter test helper — exported as @haverstack/core/testing
tests/
adapter-local/ # @haverstack/adapter-local
src/
Expand Down
3 changes: 2 additions & 1 deletion docs/design/change-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ An app that keeps a live list of notes from a hosted stack. Everything outside t
### Connecting

```ts
import { Stack, didCredentialFromKeypair } from '@haverstack/core';
import { Stack } from '@haverstack/core';
import { didCredentialFromKeypair } from '@haverstack/core/wire';
import { APIAdapter } from '@haverstack/adapter-api';

// The keypair was generated once and persisted by the app — the stack never
Expand Down
4 changes: 2 additions & 2 deletions docs/spec/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ Packages follow a naming convention that makes the adapter type discoverable:

`adapter-local` is the batteries-included package for the common local case. It wraps `NativeSQLiteRecordAdapter` and `DiskBlobAdapter` and stores attachments in an `attachments/` subdirectory next to the database file. Bearer tokens, when used, live in a separate sibling file (`<path>.tokens`, via `NativeTokenStore`) — never inside the portable stack database.

Use `combineAdapters()` from `@haverstack/core` when you want different backends for records and blobs — for example, native SQLite records with S3 blob storage:
Use `combineAdapters()` from `@haverstack/core/adapter` when you want different backends for records and blobs — for example, native SQLite records with S3 blob storage:

```ts
import { combineAdapters } from '@haverstack/core';
import { combineAdapters } from '@haverstack/core/adapter';
import { NativeSQLiteRecordAdapter } from '@haverstack/record-adapter-sqlite';
import { S3BlobAdapter } from '@haverstack/blob-adapter-s3'; // hypothetical

Expand Down
4 changes: 2 additions & 2 deletions docs/spec/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ One claim in the served topology falls outside that guarantee, and it is named r
| `did:web` | a domain in DID clothing | optional, for those who _want_ domain identity |
| `did:plc` | ATProto's rotation directory | optional, for a future ATProto bridge |

`@haverstack/core` generates and verifies `did:key` (Ed25519) via `generateDidKeypair()` / `verifyDidSignature()` / etc. (`did.ts`) using Web Crypto only — zero infrastructure, zero resolution, zero registry, no dependency. Other methods are valid `entityId` values but core doesn't mint or resolve them.
`@haverstack/core/did` generates and verifies `did:key` (Ed25519) via `generateDidKeypair()` / `verifyDidSignature()` / etc. (`did.ts`) using Web Crypto only — zero infrastructure, zero resolution, zero registry, no dependency. Other methods are valid `entityId` values but core doesn't mint or resolve them.

**Key custody is not this library's job.** `generateDidKeypair()` returns a `privateKey`; nothing in `@haverstack/core` or any adapter stores it — only the public DID travels with stack data. Where the private key lives (OS keychain, encrypted file, hardware key) and how it's backed up is an app/UX concern.

Expand Down Expand Up @@ -177,7 +177,7 @@ Token issuance is not an out-of-band secret handoff, and the handshake that repl
2. Client signs a domain-separated payload binding the server's origin, its DID and the nonce — `buildAuthChallengePayload()` builds it, `signAuthChallenge()` signs it — and sends the signature back: `POST /auth/token { did, nonce, signature }`.
3. Server verifies against the payload it builds itself (`verifyAuthChallenge()` — for `did:key` this requires no lookup at all; the public key is decoded from the DID string) and, on success, calls `StackTokenStore.createToken(did)` and returns the bearer token.

`@haverstack/core` supplies both halves of steps 2 and 3 and runs no server; a server implementation brings the endpoints, nonce storage and single-use enforcement.
`@haverstack/core/wire` supplies both halves of steps 2 and 3 and runs no server; a server implementation brings the endpoints, nonce storage and single-use enforcement.

"Access granted to the holder of key X" is verifiable with no provider, no email loop, no OAuth.

Expand Down
6 changes: 3 additions & 3 deletions docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Both are unauthenticated — they are how a token is obtained. `signature` is ba

**The token's `expiresAt` is advisory.** A client MAY renew ahead of it and MUST NOT depend on it being present — it is optional, so 401-driven renewal is the floor a client needs regardless, and `APIAdapter` uses only that today. A server should therefore read an aggressive expiry as costing a wasted round-trip per lifetime rather than as something clients will schedule around.

**What gets signed is not the nonce.** It is a domain-separated payload, built identically on both sides by `buildAuthChallengePayload()` in `@haverstack/core` — exported for exactly this reason, so a server and a client cannot each derive "the same string" and diverge on the first ambiguity:
**What gets signed is not the nonce.** It is a domain-separated payload, built identically on both sides by `buildAuthChallengePayload()` in `@haverstack/core/wire` — exported for exactly this reason, so a server and a client cannot each derive "the same string" and diverge on the first ambiguity:

```
haverstack-auth-v1\n<origin>\n<did>\n<nonce>
Expand All @@ -105,7 +105,7 @@ Two rules follow, and a server that skips either has a conformance gap rather th
- **A server MUST build the payload from its own configured public origin**, never from a request header. `Host` and `X-Forwarded-Host` are client-controlled, so deriving the origin from one lets a client choose which origin it signs for, which is the whole property being bought.
- **A server MUST verify against the payload it builds itself.** Nothing signed or claimed by the client contributes to it beyond the `did` and `nonce` fields named above.

`@haverstack/core` provides both halves — `signAuthChallenge()` / `verifyAuthChallenge()` — and `didCredentialFromKeypair()` builds the `{ did, sign }` credential `APIAdapter` takes. The credential is a **signing callback, never a private key**: key custody stays with the app (see [Identity](./identity.md)), so a caller is free to back it with a hardware key or a keychain prompt.
`@haverstack/core/wire` provides both halves — `signAuthChallenge()` / `verifyAuthChallenge()` — and `didCredentialFromKeypair()` builds the `{ did, sign }` credential `APIAdapter` takes. The credential is a **signing callback, never a private key**: key custody stays with the app (see [Identity](./identity.md)), so a caller is free to back it with a hardware key or a keychain prompt.

### Auth errors

Expand Down Expand Up @@ -424,7 +424,7 @@ GET /attachments/<fileId>?contentType=image/png&filename=photo.png
- **Everything else** is forced to `Content-Type: application/octet-stream` with `Content-Disposition: attachment` — forcing the content type alone is not sufficient, since disposition determines whether a browser treats the response as inline-renderable at all.
- **`X-Content-Type-Options: nosniff` is sent on every attachment download response**, forced or not — without it, browsers may sniff an `application/octet-stream` body back into the dangerous type the forcing just removed.

`@haverstack/core` exports the canonical implementation of this resolution and policy — `resolveAttachmentDownloadContentType()`, `isSafeAttachmentContentType()`, `inferContentTypeFromFilename()`, and the `NOSNIFF_HEADER_NAME`/`NOSNIFF_HEADER_VALUE` constants — so server implementations share one safe-list rather than each re-deriving it.
`@haverstack/core/wire` exports the canonical implementation of this resolution and policy — `resolveAttachmentDownloadContentType()`, `isSafeAttachmentContentType()`, `inferContentTypeFromFilename()`, and the `NOSNIFF_HEADER_NAME`/`NOSNIFF_HEADER_VALUE` constants — so server implementations share one safe-list rather than each re-deriving it.

The filename in `Content-Disposition` is taken from `?filename` if given, else the requester's own `_attachment@1` record (if one exists), falling back to the first record's filename otherwise.

Expand Down
13 changes: 5 additions & 8 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,7 @@
* patchContent()/deleteRecord()/etc.'s expectedVersion option.
*/

import {
assertQueryCapabilities,
StackQueryError,
buildAuthChallengePayload,
base64urlEncode,
} from '@haverstack/core';
import { StackQueryError } from '@haverstack/core';
import type {
StackAdapter,
StackRecord,
Expand All @@ -33,12 +28,14 @@ import type {
QueryResult,
Association,
Permission,
AdapterCapabilities,
RecordId,
FileId,
EntityId,
DidCredential,
} from '@haverstack/core';
import { assertQueryCapabilities } from '@haverstack/core/adapter';
import type { AdapterCapabilities } from '@haverstack/core/adapter';
import { buildAuthChallengePayload, base64urlEncode } from '@haverstack/core/wire';
import type { DidCredential } from '@haverstack/core/wire';
import type {
WireRecord,
WireQueryResponse,
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
APIAdapterHandshakeError,
APIAdapterReauthError,
} from '../src/index.js';
import { buildAuthChallengePayload } from '@haverstack/core';
import { buildAuthChallengePayload } from '@haverstack/core/wire';
import { WIRE_PROTOCOL_VERSION } from '@haverstack/wire-types';
import type { StackRecord, StackType, RecordVersion, Association } from '@haverstack/core';
import {
Expand Down
6 changes: 4 additions & 2 deletions packages/adapter-api/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ import {
StackSchemaDriftError,
StackPayloadTooLargeError,
StackTimeoutError,
} from '@haverstack/core';
import {
buildAuthChallengePayload,
verifyAuthChallenge,
base64urlDecode,
generateDidKeypair,
didCredentialFromKeypair,
} from '@haverstack/core';
} from '@haverstack/core/wire';
import { generateDidKeypair } from '@haverstack/core/did';

const BASE_URL = 'https://stack.example.com';

Expand Down
3 changes: 3 additions & 0 deletions packages/adapter-api/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@haverstack/core/did': resolve(__dirname, '../core/src/did-entry.ts'),
'@haverstack/core/wire': resolve(__dirname, '../core/src/wire-entry.ts'),
'@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter-entry.ts'),
'@haverstack/core': resolve(__dirname, '../core/src/index.ts'),
'@haverstack/wire-types': resolve(__dirname, '../wire-types/src/index.ts'),
'@haverstack/conformance-fixtures': resolve(
Expand Down
13 changes: 6 additions & 7 deletions packages/adapter-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* For most local use cases this is the only package you need.
* If you want a different blob backend (e.g. S3), import
* NativeSQLiteRecordAdapter and DiskBlobAdapter separately and
* compose them with combineAdapters() from @haverstack/core.
* compose them with combineAdapters() from @haverstack/core/adapter.
*/

import { dirname, join } from 'path';
Expand All @@ -25,20 +25,18 @@ import type {
QueryResult,
Association,
Permission,
AdapterCapabilities,
RecordId,
FileId,
BlobFileInfo,
TokenInfo,
TokenSession,
} from '@haverstack/core';
import type { AdapterCapabilities, BlobFileInfo, StackBlobAdapter } from '@haverstack/core/adapter';
import type { TokenInfo } from '@haverstack/core/wire';
import {
NativeSQLiteRecordAdapter,
NativeTokenStore,
defaultTokenStorePath,
} from '@haverstack/record-adapter-sqlite';
import { DiskBlobAdapter } from '@haverstack/blob-adapter-disk';
import type { StackBlobAdapter } from '@haverstack/core';

export {
NativeSQLiteRecordAdapter,
Expand All @@ -50,7 +48,8 @@ export type {
NativeRecordOpenOptions,
NativeTokenStoreOptions,
} from '@haverstack/record-adapter-sqlite';
export type { TokenInfo, TokenSession } from '@haverstack/core';
export type { TokenSession } from '@haverstack/core';
export type { TokenInfo } from '@haverstack/core/wire';
export { DiskBlobAdapter } from '@haverstack/blob-adapter-disk';

// -------------------------------------------------------
Expand Down Expand Up @@ -373,4 +372,4 @@ export class LocalAdapter implements StackAdapter {
}

// Also export combineAdapters for users who want to compose their own adapters
export { combineAdapters } from '@haverstack/core';
export { combineAdapters } from '@haverstack/core/adapter';
2 changes: 2 additions & 0 deletions packages/adapter-local/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@haverstack/core/wire': resolve(__dirname, '../core/src/wire-entry.ts'),
'@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter-entry.ts'),
'@haverstack/core': resolve(__dirname, '../core/src/index.ts'),
'@haverstack/record-adapter-sqlite': resolve(
__dirname,
Expand Down
3 changes: 2 additions & 1 deletion packages/blob-adapter-disk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { mkdirSync, existsSync } from 'fs';
import { readFile, writeFile, unlink, readdir, stat, rename } from 'fs/promises';
import { join } from 'path';
import { StackNotFoundError, StackQueryError } from '@haverstack/core';
import type { StackBlobAdapter, BlobFileInfo, FileId } from '@haverstack/core';
import type { FileId } from '@haverstack/core';
import type { StackBlobAdapter, BlobFileInfo } from '@haverstack/core/adapter';

const SHA256_HEX_RE = /^[0-9a-f]{64}$/;

Expand Down
1 change: 1 addition & 0 deletions packages/blob-adapter-disk/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter-entry.ts'),
'@haverstack/core': resolve(__dirname, '../core/src/index.ts'),
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ You'll also need a storage adapter:
## Quick start

```ts
import { Stack, generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core';
import { Stack } from '@haverstack/core';
import { generateDidKeypair, exportDidPrivateKeyJwk } from '@haverstack/core/did';
import { LocalAdapter } from '@haverstack/adapter-local';
import { writeFile } from 'node:fs/promises';

Expand Down
12 changes: 12 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./did": {
"import": "./dist/did-entry.js",
"types": "./dist/did-entry.d.ts"
},
"./wire": {
"import": "./dist/wire-entry.js",
"types": "./dist/wire-entry.d.ts"
},
"./adapter": {
"import": "./dist/adapter-entry.js",
"types": "./dist/adapter-entry.d.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts"
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/adapter-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @haverstack/core/adapter
* -------------------------------------------------------
* The interfaces a storage adapter implements — record storage, blob
* storage, and the capabilities an adapter declares. Audience: adapter
* implementers (see the in-repo adapters), not app or plugin code, which
* reads `stack.features` instead of these directly.
*/

export type {
StackRecordAdapter,
StackBlobAdapter,
AdapterCapabilities,
BlobFileInfo,
ExpectedVersionOptions,
} from './types.js';
export { combineAdapters } from './combine.js';
export { assertQueryCapabilities } from './stack.js';
19 changes: 19 additions & 0 deletions packages/core/src/did-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @haverstack/core/did
* -------------------------------------------------------
* Key generation, custody and signing for did:key — the mandatory-floor
* DID method (see docs/spec/identity.md). Audience: apps at first-run
* keygen and key custody, and protocol implementers verifying signatures.
*/

export { InvalidDidError, generateDidKeypair, verifyDidSignature } from './did.js';
export type { DidKeypair } from './did.js';

// No in-repo caller: haverstack/server#54 validates ENTITY_ID as a DID at
// server startup.
export { isValidDid } from './did.js';

// No in-repo caller: root README.md § Key custody documents
// exportDidPrivateKeyJwk() -> store -> importDidPrivateKeyJwk() ->
// signWithDid() as the key-custody journey an app follows.
export { signWithDid, exportDidPrivateKeyJwk, importDidPrivateKeyJwk } from './did.js';
Loading
Loading