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
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,27 @@ Planned:
## Quick start

```ts
import { Stack } from '@haverstack/core';
import { Stack, generateDidKeypair } from '@haverstack/core';
import { LocalAdapter } from '@haverstack/adapter-local';

// First run — initialize a new stack
// 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 adapter = await LocalAdapter.initialize({
path: './my-stack.db',
entityId: 'my-entity-id',
entityId: did,
timezone: 'America/New_York',
});

// Subsequent runs — open the existing stack
// const adapter = await LocalAdapter.open({ path: './my-stack.db' });

const stack = await Stack.create(adapter);
// 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' } });

// Define a type
await stack.defineType('com.example.myapp/note@1', 'Note', {
Expand Down Expand Up @@ -106,6 +113,14 @@ The fundamental unit of data. Every record has:
- **Content** — a JSON object validated against the type's schema
- Optional: `parentId`, `entityId`, `appId`, `permissions`, `associations`

### Identity

`entityId` — on records, permissions, grants, group membership, the stack owner — is a [DID](https://www.w3.org/TR/did-core/) string, e.g. `did:key:z6Mk...`. An identity is a keypair; there's no provider, directory, or domain to trust. `did:key` (a public key, encoded — nothing else) is the mandatory floor; `generateDidKeypair()` mints one. Other DID methods (`did:web`, `did:plc`, ...) are valid `entityId` values too.

The `_entity` record type is a **local profile** about a DID, not the identity itself — a petname card (`{ did, name, handle? }`) with a display name you chose for that DID. Two stacks can hold different `_entity` cards with different names for the same DID; that's correct, it's each owner's own contact card. `Stack.create(adapter, { ownerProfile })` creates the owner's own card on first run.

See [Identity](./docs/spec.md#identity) in the spec for the full model, including authentication (challenge–response, not a shared secret) and what's deliberately deferred (key rotation).

### Types

Types define the schema for a record's content. They are identified by a **namespaced, versioned string**:
Expand Down
85 changes: 66 additions & 19 deletions docs/spec.md

Large diffs are not rendered by default.

27 changes: 19 additions & 8 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,32 @@ npm install @haverstack/core

You'll also need a storage adapter:

- [`@haverstack/adapter-sqlite`](https://www.npmjs.com/package/@haverstack/adapter-sqlite) — local SQLite storage via sql.js
- [`@haverstack/adapter-local`](https://www.npmjs.com/package/@haverstack/adapter-local) — local storage (native SQLite + disk), single-app/embedded or server use

## Quick start

```ts
import { Stack } from '@haverstack/core';
import { SQLiteAdapter } from '@haverstack/adapter-sqlite';
import { Stack, generateDidKeypair } from '@haverstack/core';
import { LocalAdapter } from '@haverstack/adapter-local';

// First run — initialize a new stack
const adapter = await SQLiteAdapter.initialize({
// 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 adapter = await LocalAdapter.initialize({
path: './my-stack.db',
entityId: 'my-entity-id',
entityId: did,
timezone: 'America/New_York',
});

// Subsequent runs — open the existing stack
// const adapter = await SQLiteAdapter.open({ path: './my-stack.db' });
// const adapter = await LocalAdapter.open({ path: './my-stack.db' });

const stack = await Stack.create(adapter);
// 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' } });

// Define a type
await stack.defineType('com.example.myapp/note@1', 'Note', {
Expand Down Expand Up @@ -74,6 +81,10 @@ The fundamental unit of data. Every record has:
- **Content** — a JSON object validated against the type's schema
- Optional: `parentId`, `entityId`, `appId`, `permissions`, `associations`

### Identity

`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.md#identity) in the spec.

### Types

Types define the schema for a record's content. They are identified by a namespaced, versioned string:
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* control; exported standalone for callers that want the raw predicate.
*/

import type { Association, RecordId, StackRecord } from './types.js';
import type { Association, EntityId, RecordId, StackRecord } from './types.js';

export type AccessMode = 'read' | 'write';

Expand All @@ -30,8 +30,8 @@ export type RecordResolver = (id: RecordId) => Promise<StackRecord | null>;
*/
export async function checkAccess(
record: StackRecord,
requesterEntityId: string | null,
ownerEntityId: string | null,
requesterEntityId: EntityId | null,
ownerEntityId: EntityId | null,
mode: AccessMode,
resolveRecord: RecordResolver,
): Promise<boolean> {
Expand Down Expand Up @@ -66,7 +66,7 @@ export async function checkAccess(

async function resolveGroupRole(
groupRecordId: RecordId,
entityId: string,
entityId: EntityId,
resolveRecord: RecordResolver,
): Promise<GroupRole | null> {
const group = await resolveRecord(groupRecordId);
Expand All @@ -82,7 +82,7 @@ async function resolveGroupRole(
*/
export function groupRoleFromAssociations(
associations: Association[] | undefined,
entityId: string,
entityId: EntityId,
): GroupRole | null {
let role: GroupRole | null = null;
for (const a of associations ?? []) {
Expand Down
198 changes: 198 additions & 0 deletions packages/core/src/did.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Stack — Decentralized Identifiers
* -------------------------------------------------------
* entityId values are DID strings (see docs/spec.md § Identity). did:key —
* an Ed25519 public key, multicodec + multibase(base58btc) encoded — is the
* mandatory floor: zero infrastructure, zero resolution, verifiable by
* anyone from the string alone. Other methods (did:web, did:plc, ...) are
* valid entityId values too, but this module only knows how to mint and
* verify did:key.
*
* Uses Web Crypto (crypto.subtle) exclusively — no dependency, works in
* Node (>=22.5, matching the rest of core's floor), browsers, and Deno.
*/

// -------------------------------------------------------
// Errors
// -------------------------------------------------------

export class InvalidDidError extends Error {
constructor(message = '') {
super(message || 'Invalid DID.');
this.name = 'InvalidDidError';
}
}

// -------------------------------------------------------
// base58btc (Bitcoin alphabet) — no leading-zero-safe library dependency
// -------------------------------------------------------

const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';

const base58btcEncode = (bytes: Uint8Array): string => {
let value = 0n;
for (const byte of bytes) value = (value << 8n) | BigInt(byte);

let out = '';
while (value > 0n) {
const remainder = value % 58n;
value /= 58n;
out = BASE58_ALPHABET[Number(remainder)] + out;
}

for (const byte of bytes) {
if (byte !== 0) break;
out = '1' + out;
}

return out;
};

const base58btcDecode = (encoded: string): Uint8Array => {
let value = 0n;
for (const char of encoded) {
const digit = BASE58_ALPHABET.indexOf(char);
if (digit === -1) {
throw new InvalidDidError(`Invalid base58 character: "${char}"`);
}
value = value * 58n + BigInt(digit);
}

const bytes: number[] = [];
while (value > 0n) {
bytes.unshift(Number(value & 0xffn));
value >>= 8n;
}

for (const char of encoded) {
if (char !== '1') break;
bytes.unshift(0);
}

return new Uint8Array(bytes);
};

// -------------------------------------------------------
// did:key (Ed25519)
// -------------------------------------------------------

/** Multicodec varint prefix for "ed25519-pub" (code 0xed). */
const ED25519_MULTICODEC_PREFIX = new Uint8Array([0xed, 0x01]);
const ED25519_RAW_KEY_LENGTH = 32;
const DID_KEY_PREFIX = 'did:key:z';

export type DidKeypair = {
/** e.g. "did:key:z6Mk..." */
did: string;
publicKey: CryptoKey;
privateKey: CryptoKey;
};

/** Generate a fresh Ed25519 keypair and derive its did:key. */
export const generateDidKeypair = async (): Promise<DidKeypair> => {
const { publicKey, privateKey } = (await crypto.subtle.generateKey('Ed25519', true, [
'sign',
'verify',
])) as CryptoKeyPair;
return { did: await didFromPublicKey(publicKey), publicKey, privateKey };
};

/** Derive a did:key string from an Ed25519 CryptoKey. */
export const didFromPublicKey = async (publicKey: CryptoKey): Promise<string> => {
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', publicKey));
const prefixed = new Uint8Array(ED25519_MULTICODEC_PREFIX.length + raw.length);
prefixed.set(ED25519_MULTICODEC_PREFIX, 0);
prefixed.set(raw, ED25519_MULTICODEC_PREFIX.length);
return DID_KEY_PREFIX + base58btcEncode(prefixed);
};

/**
* Parse a did:key string and return its raw Ed25519 public key bytes, or
* null if it isn't a well-formed Ed25519 did:key (wrong prefix, bad base58,
* wrong multicodec, wrong length).
*/
export const parseDidKey = (did: string): Uint8Array | null => {
if (!did.startsWith(DID_KEY_PREFIX)) return null;

let decoded: Uint8Array;
try {
decoded = base58btcDecode(did.slice(DID_KEY_PREFIX.length));
} catch {
return null;
}

const expectedLength = ED25519_MULTICODEC_PREFIX.length + ED25519_RAW_KEY_LENGTH;
if (
decoded.length !== expectedLength ||
decoded[0] !== ED25519_MULTICODEC_PREFIX[0] ||
decoded[1] !== ED25519_MULTICODEC_PREFIX[1]
) {
return null;
}

return decoded.slice(ED25519_MULTICODEC_PREFIX.length);
};

/** Whether a string is a well-formed Ed25519 did:key. */
export const isValidDidKey = (value: string): boolean => parseDidKey(value) !== null;

/** Import the Ed25519 public key encoded in a did:key, for verification. */
export const publicKeyFromDidKey = async (did: string): Promise<CryptoKey> => {
const raw = parseDidKey(did);
if (!raw) throw new InvalidDidError(`Not a valid did:key: "${did}"`);
return crypto.subtle.importKey('raw', new Uint8Array(raw), 'Ed25519', true, ['verify']);
};

// -------------------------------------------------------
// Generic DID syntax (any method)
// -------------------------------------------------------

// did-core ABNF, simplified: "did:" method-name ":" method-specific-id
const GENERIC_DID_FORMAT = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/;

/**
* Whether a string has the generic W3C DID Core syntax (did:<method>:<id>)
* — any method, not just did:key. Use isValidDidKey() to additionally
* require the did:key method and a well-formed Ed25519 key.
*/
export const isValidDid = (value: string): boolean => GENERIC_DID_FORMAT.test(value);

// -------------------------------------------------------
// Sign / verify
// -------------------------------------------------------

export const signWithDid = async (privateKey: CryptoKey, data: Uint8Array): Promise<Uint8Array> =>
new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, new Uint8Array(data)));

/**
* Verify a signature was produced by the private key behind a did:key.
* Requires no lookup — the public key is decoded from the DID itself.
*/
export const verifyDidSignature = async (
did: string,
signature: Uint8Array,
data: Uint8Array,
): Promise<boolean> => {
const publicKey = await publicKeyFromDidKey(did);
return crypto.subtle.verify(
'Ed25519',
publicKey,
new Uint8Array(signature),
new Uint8Array(data),
);
};

// -------------------------------------------------------
// Private-key export/import
// -------------------------------------------------------
//
// Core never stores a private key itself — these exist so callers can
// persist the key material returned by generateDidKeypair() (encrypted key
// backup, OS keychain, etc.) and reconstruct it on a later run. Where that
// key lives is an app/UX concern, not this library's.

export const exportDidPrivateKeyJwk = async (privateKey: CryptoKey): Promise<JsonWebKey> =>
crypto.subtle.exportKey('jwk', privateKey);

export const importDidPrivateKeyJwk = async (jwk: JsonWebKey): Promise<CryptoKey> =>
crypto.subtle.importKey('jwk', jwk, 'Ed25519', true, ['sign']);
15 changes: 15 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type {
RecordId,
TypeId,
FileId,
EntityId,
AttachmentContent,
StackRecord,
RecordVersion,
Expand Down Expand Up @@ -105,6 +106,20 @@ export type { SchemaDriftViolation } from './schema.js';
export { validateContent, isValid } from './validate.js';
export type { ValidationError } from './validate.js';
export { applyMergePatch } from './merge.js';
export {
InvalidDidError,
generateDidKeypair,
didFromPublicKey,
parseDidKey,
isValidDidKey,
publicKeyFromDidKey,
isValidDid,
signWithDid,
verifyDidSignature,
exportDidPrivateKeyJwk,
importDidPrivateKeyJwk,
} from './did.js';
export type { DidKeypair } from './did.js';
export {
isSafeAttachmentContentType,
inferContentTypeFromFilename,
Expand Down
Loading
Loading