@weaveprotocol/core 0.1.0
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 +1106 -0
- package/dist/elements/auth-styles.d.ts +18 -0
- package/dist/elements/auth-styles.d.ts.map +1 -0
- package/dist/elements/auth-styles.js +150 -0
- package/dist/elements/auth-styles.js.map +1 -0
- package/dist/elements/dom.d.ts +28 -0
- package/dist/elements/dom.d.ts.map +1 -0
- package/dist/elements/dom.js +74 -0
- package/dist/elements/dom.js.map +1 -0
- package/dist/elements/index.d.ts +13 -0
- package/dist/elements/index.d.ts.map +1 -0
- package/dist/elements/index.js +11 -0
- package/dist/elements/index.js.map +1 -0
- package/dist/elements/weave-auth.d.ts +52 -0
- package/dist/elements/weave-auth.d.ts.map +1 -0
- package/dist/elements/weave-auth.js +427 -0
- package/dist/elements/weave-auth.js.map +1 -0
- package/dist/identity/account-store.d.ts +113 -0
- package/dist/identity/account-store.d.ts.map +1 -0
- package/dist/identity/account-store.js +305 -0
- package/dist/identity/account-store.js.map +1 -0
- package/dist/identity/account-vault.d.ts +199 -0
- package/dist/identity/account-vault.d.ts.map +1 -0
- package/dist/identity/account-vault.js +251 -0
- package/dist/identity/account-vault.js.map +1 -0
- package/dist/identity/agent-note.d.ts +19 -0
- package/dist/identity/agent-note.d.ts.map +1 -0
- package/dist/identity/agent-note.js +29 -0
- package/dist/identity/agent-note.js.map +1 -0
- package/dist/identity/crypto-p256.d.ts +7 -0
- package/dist/identity/crypto-p256.d.ts.map +1 -0
- package/dist/identity/crypto-p256.js +84 -0
- package/dist/identity/crypto-p256.js.map +1 -0
- package/dist/identity/device-key.d.ts +60 -0
- package/dist/identity/device-key.d.ts.map +1 -0
- package/dist/identity/device-key.js +103 -0
- package/dist/identity/device-key.js.map +1 -0
- package/dist/identity/did.d.ts +22 -0
- package/dist/identity/did.d.ts.map +1 -0
- package/dist/identity/did.js +37 -0
- package/dist/identity/did.js.map +1 -0
- package/dist/identity/folder-account.d.ts +65 -0
- package/dist/identity/folder-account.d.ts.map +1 -0
- package/dist/identity/folder-account.js +115 -0
- package/dist/identity/folder-account.js.map +1 -0
- package/dist/identity/identity-manager.d.ts +36 -0
- package/dist/identity/identity-manager.d.ts.map +1 -0
- package/dist/identity/identity-manager.js +115 -0
- package/dist/identity/identity-manager.js.map +1 -0
- package/dist/identity/index.d.ts +10 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/keys.d.ts +24 -0
- package/dist/identity/keys.d.ts.map +1 -0
- package/dist/identity/keys.js +38 -0
- package/dist/identity/keys.js.map +1 -0
- package/dist/identity/pairing.d.ts +83 -0
- package/dist/identity/pairing.d.ts.map +1 -0
- package/dist/identity/pairing.js +120 -0
- package/dist/identity/pairing.js.map +1 -0
- package/dist/identity/passkey-diagnostics.d.ts +54 -0
- package/dist/identity/passkey-diagnostics.d.ts.map +1 -0
- package/dist/identity/passkey-diagnostics.js +178 -0
- package/dist/identity/passkey-diagnostics.js.map +1 -0
- package/dist/identity/recovery-code.d.ts +50 -0
- package/dist/identity/recovery-code.d.ts.map +1 -0
- package/dist/identity/recovery-code.js +116 -0
- package/dist/identity/recovery-code.js.map +1 -0
- package/dist/identity/root-signer.d.ts +59 -0
- package/dist/identity/root-signer.d.ts.map +1 -0
- package/dist/identity/root-signer.js +44 -0
- package/dist/identity/root-signer.js.map +1 -0
- package/dist/identity/ucan.d.ts +164 -0
- package/dist/identity/ucan.d.ts.map +1 -0
- package/dist/identity/ucan.js +273 -0
- package/dist/identity/ucan.js.map +1 -0
- package/dist/identity/webauthn.d.ts +87 -0
- package/dist/identity/webauthn.d.ts.map +1 -0
- package/dist/identity/webauthn.js +155 -0
- package/dist/identity/webauthn.js.map +1 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/dist/network/index.d.ts +17 -0
- package/dist/network/index.d.ts.map +1 -0
- package/dist/network/index.js +10 -0
- package/dist/network/index.js.map +1 -0
- package/dist/network/introductions.d.ts +85 -0
- package/dist/network/introductions.d.ts.map +1 -0
- package/dist/network/introductions.js +94 -0
- package/dist/network/introductions.js.map +1 -0
- package/dist/network/local-transport.d.ts +22 -0
- package/dist/network/local-transport.d.ts.map +1 -0
- package/dist/network/local-transport.js +55 -0
- package/dist/network/local-transport.js.map +1 -0
- package/dist/network/mesh.d.ts +31 -0
- package/dist/network/mesh.d.ts.map +1 -0
- package/dist/network/mesh.js +332 -0
- package/dist/network/mesh.js.map +1 -0
- package/dist/network/multi-signaling.d.ts +29 -0
- package/dist/network/multi-signaling.d.ts.map +1 -0
- package/dist/network/multi-signaling.js +125 -0
- package/dist/network/multi-signaling.js.map +1 -0
- package/dist/network/network-manager.d.ts +37 -0
- package/dist/network/network-manager.d.ts.map +1 -0
- package/dist/network/network-manager.js +64 -0
- package/dist/network/network-manager.js.map +1 -0
- package/dist/network/peer-auth.d.ts +98 -0
- package/dist/network/peer-auth.d.ts.map +1 -0
- package/dist/network/peer-auth.js +86 -0
- package/dist/network/peer-auth.js.map +1 -0
- package/dist/network/rtc-transport.d.ts +18 -0
- package/dist/network/rtc-transport.d.ts.map +1 -0
- package/dist/network/rtc-transport.js +149 -0
- package/dist/network/rtc-transport.js.map +1 -0
- package/dist/network/signaling.d.ts +40 -0
- package/dist/network/signaling.d.ts.map +1 -0
- package/dist/network/signaling.js +124 -0
- package/dist/network/signaling.js.map +1 -0
- package/dist/network/transport.d.ts +50 -0
- package/dist/network/transport.d.ts.map +1 -0
- package/dist/network/transport.js +13 -0
- package/dist/network/transport.js.map +1 -0
- package/dist/network/ws-transport.d.ts +42 -0
- package/dist/network/ws-transport.d.ts.map +1 -0
- package/dist/network/ws-transport.js +194 -0
- package/dist/network/ws-transport.js.map +1 -0
- package/dist/node/actions.d.ts +62 -0
- package/dist/node/actions.d.ts.map +1 -0
- package/dist/node/actions.js +500 -0
- package/dist/node/actions.js.map +1 -0
- package/dist/node/carrier.d.ts +85 -0
- package/dist/node/carrier.d.ts.map +1 -0
- package/dist/node/carrier.js +192 -0
- package/dist/node/carrier.js.map +1 -0
- package/dist/node/copy.d.ts +38 -0
- package/dist/node/copy.d.ts.map +1 -0
- package/dist/node/copy.js +53 -0
- package/dist/node/copy.js.map +1 -0
- package/dist/node/index.d.ts +16 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +11 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.d.ts +12 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +872 -0
- package/dist/node/node.js.map +1 -0
- package/dist/node/space-runtime.d.ts +144 -0
- package/dist/node/space-runtime.d.ts.map +1 -0
- package/dist/node/space-runtime.js +1221 -0
- package/dist/node/space-runtime.js.map +1 -0
- package/dist/node/stores.d.ts +45 -0
- package/dist/node/stores.d.ts.map +1 -0
- package/dist/node/stores.js +31 -0
- package/dist/node/stores.js.map +1 -0
- package/dist/node/types.d.ts +508 -0
- package/dist/node/types.d.ts.map +1 -0
- package/dist/node/types.js +2 -0
- package/dist/node/types.js.map +1 -0
- package/dist/privacy/index.d.ts +4 -0
- package/dist/privacy/index.d.ts.map +1 -0
- package/dist/privacy/index.js +4 -0
- package/dist/privacy/index.js.map +1 -0
- package/dist/privacy/key-distribution.d.ts +42 -0
- package/dist/privacy/key-distribution.d.ts.map +1 -0
- package/dist/privacy/key-distribution.js +50 -0
- package/dist/privacy/key-distribution.js.map +1 -0
- package/dist/privacy/privacy-guard.d.ts +24 -0
- package/dist/privacy/privacy-guard.d.ts.map +1 -0
- package/dist/privacy/privacy-guard.js +87 -0
- package/dist/privacy/privacy-guard.js.map +1 -0
- package/dist/privacy/space-encryption.d.ts +48 -0
- package/dist/privacy/space-encryption.d.ts.map +1 -0
- package/dist/privacy/space-encryption.js +61 -0
- package/dist/privacy/space-encryption.js.map +1 -0
- package/dist/query/engine.d.ts +27 -0
- package/dist/query/engine.d.ts.map +1 -0
- package/dist/query/engine.js +84 -0
- package/dist/query/engine.js.map +1 -0
- package/dist/query/filter.d.ts +15 -0
- package/dist/query/filter.d.ts.map +1 -0
- package/dist/query/filter.js +207 -0
- package/dist/query/filter.js.map +1 -0
- package/dist/query/types.d.ts +144 -0
- package/dist/query/types.d.ts.map +1 -0
- package/dist/query/types.js +22 -0
- package/dist/query/types.js.map +1 -0
- package/dist/react/context.d.ts +81 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +95 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/index.d.ts +40 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +36 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/use-live.d.ts +16 -0
- package/dist/react/use-live.d.ts.map +1 -0
- package/dist/react/use-live.js +55 -0
- package/dist/react/use-live.js.map +1 -0
- package/dist/react/use-query.d.ts +17 -0
- package/dist/react/use-query.d.ts.map +1 -0
- package/dist/react/use-query.js +23 -0
- package/dist/react/use-query.js.map +1 -0
- package/dist/react/use-space.d.ts +29 -0
- package/dist/react/use-space.d.ts.map +1 -0
- package/dist/react/use-space.js +48 -0
- package/dist/react/use-space.js.map +1 -0
- package/dist/react/use-spaces.d.ts +19 -0
- package/dist/react/use-spaces.d.ts.map +1 -0
- package/dist/react/use-spaces.js +55 -0
- package/dist/react/use-spaces.js.map +1 -0
- package/dist/react/use-weave-auth.d.ts +10 -0
- package/dist/react/use-weave-auth.d.ts.map +1 -0
- package/dist/react/use-weave-auth.js +15 -0
- package/dist/react/use-weave-auth.js.map +1 -0
- package/dist/react/weave-auth.d.ts +17 -0
- package/dist/react/weave-auth.d.ts.map +1 -0
- package/dist/react/weave-auth.js +38 -0
- package/dist/react/weave-auth.js.map +1 -0
- package/dist/records/describe.d.ts +38 -0
- package/dist/records/describe.d.ts.map +1 -0
- package/dist/records/describe.js +101 -0
- package/dist/records/describe.js.map +1 -0
- package/dist/records/links.d.ts +14 -0
- package/dist/records/links.d.ts.map +1 -0
- package/dist/records/links.js +25 -0
- package/dist/records/links.js.map +1 -0
- package/dist/records/rules.d.ts +59 -0
- package/dist/records/rules.d.ts.map +1 -0
- package/dist/records/rules.js +118 -0
- package/dist/records/rules.js.map +1 -0
- package/dist/records/version.d.ts +54 -0
- package/dist/records/version.d.ts.map +1 -0
- package/dist/records/version.js +70 -0
- package/dist/records/version.js.map +1 -0
- package/dist/schema/collection-def.d.ts +96 -0
- package/dist/schema/collection-def.d.ts.map +1 -0
- package/dist/schema/collection-def.js +272 -0
- package/dist/schema/collection-def.js.map +1 -0
- package/dist/schema/expression.d.ts +69 -0
- package/dist/schema/expression.d.ts.map +1 -0
- package/dist/schema/expression.js +92 -0
- package/dist/schema/expression.js.map +1 -0
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +4 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/schema-engine.d.ts +42 -0
- package/dist/schema/schema-engine.d.ts.map +1 -0
- package/dist/schema/schema-engine.js +36 -0
- package/dist/schema/schema-engine.js.map +1 -0
- package/dist/schema/signer.d.ts +27 -0
- package/dist/schema/signer.d.ts.map +1 -0
- package/dist/schema/signer.js +36 -0
- package/dist/schema/signer.js.map +1 -0
- package/dist/schemas/apps.d.ts +88 -0
- package/dist/schemas/apps.d.ts.map +1 -0
- package/dist/schemas/apps.js +168 -0
- package/dist/schemas/apps.js.map +1 -0
- package/dist/schemas/index.d.ts +410 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +208 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/screens.d.ts +67 -0
- package/dist/schemas/screens.d.ts.map +1 -0
- package/dist/schemas/screens.js +242 -0
- package/dist/schemas/screens.js.map +1 -0
- package/dist/session/agent-link.d.ts +79 -0
- package/dist/session/agent-link.d.ts.map +1 -0
- package/dist/session/agent-link.js +251 -0
- package/dist/session/agent-link.js.map +1 -0
- package/dist/session/auth.d.ts +233 -0
- package/dist/session/auth.d.ts.map +1 -0
- package/dist/session/auth.js +783 -0
- package/dist/session/auth.js.map +1 -0
- package/dist/session/connect.d.ts +211 -0
- package/dist/session/connect.d.ts.map +1 -0
- package/dist/session/connect.js +355 -0
- package/dist/session/connect.js.map +1 -0
- package/dist/session/connection.d.ts +70 -0
- package/dist/session/connection.d.ts.map +1 -0
- package/dist/session/connection.js +120 -0
- package/dist/session/connection.js.map +1 -0
- package/dist/session/credentials.d.ts +44 -0
- package/dist/session/credentials.d.ts.map +1 -0
- package/dist/session/credentials.js +60 -0
- package/dist/session/credentials.js.map +1 -0
- package/dist/session/index.d.ts +25 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +18 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/pairing.d.ts +59 -0
- package/dist/session/pairing.d.ts.map +1 -0
- package/dist/session/pairing.js +144 -0
- package/dist/session/pairing.js.map +1 -0
- package/dist/session/places.d.ts +62 -0
- package/dist/session/places.d.ts.map +1 -0
- package/dist/session/places.js +101 -0
- package/dist/session/places.js.map +1 -0
- package/dist/session/stay-signed-in.d.ts +37 -0
- package/dist/session/stay-signed-in.d.ts.map +1 -0
- package/dist/session/stay-signed-in.js +124 -0
- package/dist/session/stay-signed-in.js.map +1 -0
- package/dist/space/account-registry.d.ts +64 -0
- package/dist/space/account-registry.d.ts.map +1 -0
- package/dist/space/account-registry.js +66 -0
- package/dist/space/account-registry.js.map +1 -0
- package/dist/space/index.d.ts +3 -0
- package/dist/space/index.d.ts.map +1 -0
- package/dist/space/index.js +3 -0
- package/dist/space/index.js.map +1 -0
- package/dist/space/pass.d.ts +63 -0
- package/dist/space/pass.d.ts.map +1 -0
- package/dist/space/pass.js +54 -0
- package/dist/space/pass.js.map +1 -0
- package/dist/space/presets.d.ts +31 -0
- package/dist/space/presets.d.ts.map +1 -0
- package/dist/space/presets.js +24 -0
- package/dist/space/presets.js.map +1 -0
- package/dist/space/roles.d.ts +186 -0
- package/dist/space/roles.d.ts.map +1 -0
- package/dist/space/roles.js +500 -0
- package/dist/space/roles.js.map +1 -0
- package/dist/space/space-access.d.ts +80 -0
- package/dist/space/space-access.d.ts.map +1 -0
- package/dist/space/space-access.js +135 -0
- package/dist/space/space-access.js.map +1 -0
- package/dist/space/space-manager.d.ts +87 -0
- package/dist/space/space-manager.d.ts.map +1 -0
- package/dist/space/space-manager.js +187 -0
- package/dist/space/space-manager.js.map +1 -0
- package/dist/storage/directory-access.d.ts +78 -0
- package/dist/storage/directory-access.d.ts.map +1 -0
- package/dist/storage/directory-access.js +164 -0
- package/dist/storage/directory-access.js.map +1 -0
- package/dist/storage/encrypted-adapter.d.ts +49 -0
- package/dist/storage/encrypted-adapter.d.ts.map +1 -0
- package/dist/storage/encrypted-adapter.js +106 -0
- package/dist/storage/encrypted-adapter.js.map +1 -0
- package/dist/storage/folder-adapter.d.ts +105 -0
- package/dist/storage/folder-adapter.d.ts.map +1 -0
- package/dist/storage/folder-adapter.js +280 -0
- package/dist/storage/folder-adapter.js.map +1 -0
- package/dist/storage/folder-reconcile.d.ts +53 -0
- package/dist/storage/folder-reconcile.d.ts.map +1 -0
- package/dist/storage/folder-reconcile.js +65 -0
- package/dist/storage/folder-reconcile.js.map +1 -0
- package/dist/storage/index.d.ts +10 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +9 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/indexeddb-adapter.d.ts +12 -0
- package/dist/storage/indexeddb-adapter.d.ts.map +1 -0
- package/dist/storage/indexeddb-adapter.js +190 -0
- package/dist/storage/indexeddb-adapter.js.map +1 -0
- package/dist/storage/mst.d.ts +121 -0
- package/dist/storage/mst.d.ts.map +1 -0
- package/dist/storage/mst.js +402 -0
- package/dist/storage/mst.js.map +1 -0
- package/dist/storage/storage-provider.d.ts +85 -0
- package/dist/storage/storage-provider.d.ts.map +1 -0
- package/dist/storage/storage-provider.js +220 -0
- package/dist/storage/storage-provider.js.map +1 -0
- package/dist/sync/anti-entropy.d.ts +49 -0
- package/dist/sync/anti-entropy.d.ts.map +1 -0
- package/dist/sync/anti-entropy.js +62 -0
- package/dist/sync/anti-entropy.js.map +1 -0
- package/dist/sync/index.d.ts +4 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +4 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/sync-engine.d.ts +71 -0
- package/dist/sync/sync-engine.d.ts.map +1 -0
- package/dist/sync/sync-engine.js +309 -0
- package/dist/sync/sync-engine.js.map +1 -0
- package/dist/sync/sync-messages.d.ts +75 -0
- package/dist/sync/sync-messages.d.ts.map +1 -0
- package/dist/sync/sync-messages.js +12 -0
- package/dist/sync/sync-messages.js.map +1 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/encoding.d.ts +63 -0
- package/dist/utils/encoding.d.ts.map +1 -0
- package/dist/utils/encoding.js +130 -0
- package/dist/utils/encoding.js.map +1 -0
- package/dist/utils/errors.d.ts +43 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +28 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/events.d.ts +19 -0
- package/dist/utils/events.d.ts.map +1 -0
- package/dist/utils/events.js +27 -0
- package/dist/utils/events.js.map +1 -0
- package/dist/utils/hash.d.ts +23 -0
- package/dist/utils/hash.d.ts.map +1 -0
- package/dist/utils/hash.js +49 -0
- package/dist/utils/hash.js.map +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/validation/capability-gate.d.ts +50 -0
- package/dist/validation/capability-gate.d.ts.map +1 -0
- package/dist/validation/capability-gate.js +71 -0
- package/dist/validation/capability-gate.js.map +1 -0
- package/dist/validation/crypto-gate.d.ts +16 -0
- package/dist/validation/crypto-gate.d.ts.map +1 -0
- package/dist/validation/crypto-gate.js +37 -0
- package/dist/validation/crypto-gate.js.map +1 -0
- package/dist/validation/index.d.ts +6 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +6 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/stateful-gate.d.ts +15 -0
- package/dist/validation/stateful-gate.d.ts.map +1 -0
- package/dist/validation/stateful-gate.js +50 -0
- package/dist/validation/stateful-gate.js.map +1 -0
- package/dist/validation/structural-gate.d.ts +18 -0
- package/dist/validation/structural-gate.d.ts.map +1 -0
- package/dist/validation/structural-gate.js +54 -0
- package/dist/validation/structural-gate.js.map +1 -0
- package/dist/validation/validation-engine.d.ts +28 -0
- package/dist/validation/validation-engine.d.ts.map +1 -0
- package/dist/validation/validation-engine.js +38 -0
- package/dist/validation/validation-engine.js.map +1 -0
- package/package.json +107 -0
package/README.md
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
# @weaveprotocol/core
|
|
2
|
+
|
|
3
|
+
A peer-to-peer data protocol for the browser. You own your identity as a
|
|
4
|
+
written-down code, keep your data in signed records that sync directly between
|
|
5
|
+
devices, and every app is a view onto that data rather than its owner.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
11
|
+
│ Applications │
|
|
12
|
+
├──────────────┬───────────┬────────────┬──────────┬───────────┤
|
|
13
|
+
│ Accounts │ Spaces │ Validation │ Privacy │ Sync │
|
|
14
|
+
│ seed, vault, │ roles, │ crypto → │ AES-GCM │ MST anti- │
|
|
15
|
+
│ root signer, │ members × │ structural │ per │ entropy │
|
|
16
|
+
│ UCAN, pairing│ pub/priv │ → UCAN │ space │ gossip │
|
|
17
|
+
├──────────────┴───────────┴────────────┴──────────┴───────────┤
|
|
18
|
+
│ Storage: Merkle Search Tree over a StorageAdapter │
|
|
19
|
+
│ IndexedDB (per origin) · data folder (shared by origins) │
|
|
20
|
+
├──────────────────────────────────────────────────────────────┤
|
|
21
|
+
│ Network: WebRTC data channels │
|
|
22
|
+
│ several relays at once · peers introduce peers │
|
|
23
|
+
├──────────────────────────────────────────────────────────────┤
|
|
24
|
+
│ Web Crypto · WebAuthn · IndexedDB · File System Access · │
|
|
25
|
+
│ WebRTC · @noble/curves · @scure/base │
|
|
26
|
+
└──────────────────────────────────────────────────────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Key Principles
|
|
30
|
+
|
|
31
|
+
- **No authority.** No server issues identities or holds the truth. Relays only
|
|
32
|
+
introduce peers; an always-on node adds availability, never authority.
|
|
33
|
+
- **Apps are views.** Data lives in spaces the user owns, as signed records any
|
|
34
|
+
app can read and verify.
|
|
35
|
+
- **Local-first.** Works offline, syncs when peers are reachable.
|
|
36
|
+
- **Few, boring dependencies.** Native browser APIs first. Where a problem is
|
|
37
|
+
hard and already solved — elliptic-curve arithmetic, for one — a very stable,
|
|
38
|
+
widely used library instead of our own. See [docs/DEPENDENCIES.md](docs/DEPENDENCIES.md).
|
|
39
|
+
- **Isomorphic.** Runs in browsers, Node and Bun via `globalThis`.
|
|
40
|
+
- **Functional.** Plain functions and frozen data, no class hierarchies.
|
|
41
|
+
- **Standard Schema.** Bring your own validator (Zod, Valibot, ArkType, …).
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import {
|
|
47
|
+
generateSeed, seedToRecoveryCode, createIdentityManager, createLocalRootSigner,
|
|
48
|
+
publicKeyToDid, P256_MULTICODEC, createSigner, createExpression,
|
|
49
|
+
createIndexedDBAdapter, createStorageProvider, createSpaceManager,
|
|
50
|
+
} from '@weaveprotocol/core';
|
|
51
|
+
|
|
52
|
+
// 1. An account is a 16-byte seed. Show the code once; the user keeps it.
|
|
53
|
+
const seed = generateSeed();
|
|
54
|
+
console.log(seedToRecoveryCode(seed)); // 'K7N6-ERYP-68TZ-A7HN-VJW3-QWKN-CG'
|
|
55
|
+
|
|
56
|
+
const manager = createIdentityManager();
|
|
57
|
+
const me = await manager.fromSeed(seed); // same seed → same DID, anywhere
|
|
58
|
+
const provider = manager.getProvider();
|
|
59
|
+
|
|
60
|
+
// 2. The root key signs one thing: permission for a session key to write.
|
|
61
|
+
const root = createLocalRootSigner(me, provider);
|
|
62
|
+
const session = await provider.generateKeyPair();
|
|
63
|
+
const sessionDid = publicKeyToDid(await provider.exportPublicKey(session.publicKey), P256_MULTICODEC);
|
|
64
|
+
const ucan = await root.delegate({
|
|
65
|
+
audience: sessionDid,
|
|
66
|
+
capabilities: [{ with: '*', can: 'expression/*' }],
|
|
67
|
+
expiration: Math.floor(Date.now() / 1000) + 3600,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 3. A space to put things in
|
|
71
|
+
const spaces = createSpaceManager(await createIndexedDBAdapter('my-app/registry'));
|
|
72
|
+
const { space } = await spaces.create({ name: 'Notes', visibility: 'public', creator: me.did });
|
|
73
|
+
|
|
74
|
+
// 4. A signed record, stored in that space's own Merkle tree
|
|
75
|
+
const storage = createStorageProvider(await createIndexedDBAdapter(`my-app/space/${space.id}`));
|
|
76
|
+
const signed = await createSigner(provider).sign(
|
|
77
|
+
createExpression({
|
|
78
|
+
author: sessionDid,
|
|
79
|
+
collection: 'app.example.note',
|
|
80
|
+
space: space.id,
|
|
81
|
+
body: { text: 'Hello, decentralized world!' },
|
|
82
|
+
proof: ucan.encoded,
|
|
83
|
+
}),
|
|
84
|
+
session.privateKey,
|
|
85
|
+
);
|
|
86
|
+
await storage.addExpression(signed);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Syncing it to other devices is a network manager plus a sync engine with the
|
|
90
|
+
validation engine in front — see *Sync* below. Or skip all of this and use a
|
|
91
|
+
node, which does the wiring for you — next.
|
|
92
|
+
|
|
93
|
+
## The node — start here
|
|
94
|
+
|
|
95
|
+
Most applications never touch the modules below directly. `createNode` wires an
|
|
96
|
+
identity, its spaces, validation, encryption and sync into one object, and its
|
|
97
|
+
API is plain data in and out:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { createNode, createIdentityManager, createLocalRootSigner, indexedDBStores, rolePresets } from '@weaveprotocol/core';
|
|
101
|
+
|
|
102
|
+
const manager = createIdentityManager();
|
|
103
|
+
const me = await manager.fromRecoveryCode(code);
|
|
104
|
+
|
|
105
|
+
const node = await createNode({
|
|
106
|
+
signer: createLocalRootSigner(me, manager.getProvider()), // or anything that signs
|
|
107
|
+
stores: indexedDBStores('my-app'), // or folderStores(directory, …)
|
|
108
|
+
network: { relays: ['wss://relay.example'] },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const space = await node.spaces.create({ name: 'Groceries', visibility: 'private', ...rolePresets.team });
|
|
112
|
+
const milk = await node.records.put(space.id, 'app.todo.item', { text: 'milk', done: false });
|
|
113
|
+
await node.records.update(space.id, milk.key, { text: 'milk', done: true }); // same key, next version
|
|
114
|
+
node.subscribe((event) => { if (event.type === 'records') redraw(); });
|
|
115
|
+
|
|
116
|
+
const invite = await node.spaces.invite(space.id); // a friend calls node.spaces.join(invite) — and joins as an Editor
|
|
117
|
+
const view = await node.spaces.invite(space.id, { write: false }); // they can read, not change
|
|
118
|
+
await node.spaces.closeInvite(space.id, invite); // nobody else joins with that link
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
What it takes care of:
|
|
122
|
+
|
|
123
|
+
- **One root signature an hour.** The node signs with a session key and asks the
|
|
124
|
+
root signer for a fresh delegation before the old one runs out.
|
|
125
|
+
- **A record keeps its key; edits are versions.** `update` writes the next
|
|
126
|
+
version — same key, `seq` one higher, `prev` naming the version it replaces —
|
|
127
|
+
and `delete` writes a version marked deleted. Which version is current is
|
|
128
|
+
decided by `seq`, then id, never by a clock: a replayed old version cannot
|
|
129
|
+
roll a record back, a delete stays deleted, and two devices that edited apart
|
|
130
|
+
agree on the winner. Only the current version is kept, unless a collection
|
|
131
|
+
is defined with `history: 'all'`, which keeps every version as a hash-linked
|
|
132
|
+
chain (`records.history`). Anyone who may write in a space may edit and delete
|
|
133
|
+
in it.
|
|
134
|
+
- **Records outlive their session.** A delegation is judged at the moment a
|
|
135
|
+
record was signed, so a peer arriving next week still accepts last week's data.
|
|
136
|
+
- **Unknown collections are kept.** Records in collections the node has no
|
|
137
|
+
schema for are stored and synced on the strength of their signature and
|
|
138
|
+
capability, so an always-on node — or an agent inventing a collection — does
|
|
139
|
+
not need every app's schema.
|
|
140
|
+
|
|
141
|
+
Every operation is also described in `NODE_ACTIONS` — a name, a sentence and a
|
|
142
|
+
JSON Schema for its input — which is what the CLI, MCP and WebMCP front ends are
|
|
143
|
+
generated from. `runAction(node, 'records_put', { … })` runs one by name.
|
|
144
|
+
|
|
145
|
+
## Signing in — the element, and React
|
|
146
|
+
|
|
147
|
+
Getting to a node takes a sign-in flow: where the data lives (a pod or this
|
|
148
|
+
browser), which account, the ways into it (its password, a passkey, a device
|
|
149
|
+
password), creating one, staying signed in, arriving from a phone-pairing QR.
|
|
150
|
+
The protocol ships it, so an app does not write it:
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<weave-auth app-name="Todo" relays="wss://relay.example"></weave-auth>
|
|
154
|
+
<script type="module">
|
|
155
|
+
import '@weaveprotocol/core/elements';
|
|
156
|
+
document.querySelector('weave-auth').addEventListener('weave-session', (event) => {
|
|
157
|
+
const session = event.detail.session; // { account, did, sessionDid, node }, or null
|
|
158
|
+
if (session) start(session.node);
|
|
159
|
+
});
|
|
160
|
+
</script>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The element fits whatever it is put in — a page, a modal, a side panel — by
|
|
164
|
+
sizing to its container, and draws nothing once someone is in. It renders into
|
|
165
|
+
the page rather than a shadow root, because password managers fill forms there
|
|
166
|
+
reliably and the account password living in one is the point. Colours, font and
|
|
167
|
+
radius are custom properties (`--weave-accent`, `--weave-font`, …).
|
|
168
|
+
|
|
169
|
+
Underneath it is `createWeaveAuth` (`@weaveprotocol/core/session`): the same flow as
|
|
170
|
+
state and actions, with no framework. The element draws it; an app that wants
|
|
171
|
+
its own screens draws it itself. Either way the seed stays inside it.
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { createWeaveAuth } from '@weaveprotocol/core/session';
|
|
175
|
+
import { WeaveProvider, WeaveAuth, useWeave, useNode, useQuery } from '@weaveprotocol/core/react';
|
|
176
|
+
|
|
177
|
+
const auth = createWeaveAuth({ appName: 'Todo', network: { relays: ['wss://relay.example'] } });
|
|
178
|
+
|
|
179
|
+
createRoot(root).render(
|
|
180
|
+
<WeaveProvider auth={auth}>
|
|
181
|
+
<App />
|
|
182
|
+
</WeaveProvider>,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
function App() {
|
|
186
|
+
const { state } = useWeave();
|
|
187
|
+
if (state?.stage !== 'ready') return <WeaveAuth />;
|
|
188
|
+
return <Todos space={…} />;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function Todos({ space }) {
|
|
192
|
+
// Re-renders as records change here or arrive from peers.
|
|
193
|
+
const { result } = useQuery(space, { collection: 'app.todo.item', sort: { '@createdAt': 'asc' } });
|
|
194
|
+
const node = useNode();
|
|
195
|
+
const add = (text) => node.records.put(space, 'app.todo.item', { text, done: false });
|
|
196
|
+
…
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Everything below the provider asks for what it needs:
|
|
201
|
+
|
|
202
|
+
| Hook | Gives |
|
|
203
|
+
|---|---|
|
|
204
|
+
| `useWeave()` | The flow, its state and the session — or nulls, before sign-in |
|
|
205
|
+
| `useAuth()` / `useSession()` / `useNode()` | The same, for components that only exist once someone is in |
|
|
206
|
+
| `useSpaces()` | The account's spaces, kept current, with `create`, `join`, `leave` |
|
|
207
|
+
| `useQuery(space, query)` | Records matching a query, kept current |
|
|
208
|
+
| `useRecord(space, key)` / `useLinked(space, key)` | One record; what points at it |
|
|
209
|
+
| `useCollections(space)` / `useProfiles(space)` / `useSpaceStatus(space)` | What a space holds, who is in it, whether it is connected |
|
|
210
|
+
| `useCan(space, action, target)` | Whether this account may create, edit or delete — for hiding a button |
|
|
211
|
+
| `useOpenSpace(space)` | Keeps a space syncing while a view is on screen |
|
|
212
|
+
| `useLive(space, load, deps)` | Anything else, reloaded as the space changes |
|
|
213
|
+
|
|
214
|
+
An app connected to an account home passes its node instead:
|
|
215
|
+
`<WeaveProvider node={node}>`. React is an optional peer dependency; only
|
|
216
|
+
`@weaveprotocol/core/react` imports it.
|
|
217
|
+
|
|
218
|
+
## Apps without the seed — the account home
|
|
219
|
+
|
|
220
|
+
An app does not have to sign anyone in at all. It can ask an **account home** —
|
|
221
|
+
a page, at an address the person chose, that holds their account — for access,
|
|
222
|
+
and never see the seed. `home/` is one, ready to deploy as your own:
|
|
223
|
+
|
|
224
|
+
[](https://app.netlify.com/start/deploy?repository=https://github.com/leifriksheim/weave&base=home)
|
|
225
|
+
[](https://vercel.com/new/clone?repository-url=https://github.com/leifriksheim/weave&root-directory=home)
|
|
226
|
+
|
|
227
|
+
An app connects with `createWeaveConnection` — the twin of `createWeaveAuth`,
|
|
228
|
+
for apps:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { createWeaveConnection } from '@weaveprotocol/core/session';
|
|
232
|
+
import { WeaveProvider, useConnection } from '@weaveprotocol/core/react';
|
|
233
|
+
|
|
234
|
+
const connection = createWeaveConnection({
|
|
235
|
+
home: 'https://weave-home.netlify.app/connect',
|
|
236
|
+
request: {
|
|
237
|
+
name: 'Todo',
|
|
238
|
+
access: 'write', // or 'read'
|
|
239
|
+
scope: 'spaces', // or 'account': every space
|
|
240
|
+
create: [{ name: 'Todos', visibility: 'private' }], // made by the home, in the account
|
|
241
|
+
},
|
|
242
|
+
network: { relays },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
<WeaveProvider connection={connection}><App /></WeaveProvider>;
|
|
246
|
+
|
|
247
|
+
function App() {
|
|
248
|
+
const { connection, state } = useConnection();
|
|
249
|
+
if (state.status !== 'ready') return <button onClick={() => connection.connect()}>Connect with Weave</button>;
|
|
250
|
+
return <Todos />; // useNode(), useQuery(…) — the same hooks as anywhere
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
It remembers the grant between visits, starts the node from it, and says
|
|
255
|
+
`expired` when the note runs out; connecting again renews it.
|
|
256
|
+
|
|
257
|
+
The `home` an app names is only a suggestion. The home belongs to the person:
|
|
258
|
+
`connection.connect('weave.example.com')` uses their own, and the app remembers
|
|
259
|
+
it — for reconnecting and for "account settings". The grant carries the home's
|
|
260
|
+
relays, and the app joins them, so an app and a home configured with different
|
|
261
|
+
relays still meet. Underneath are
|
|
262
|
+
`connectToHome`, `startConnectedNode` and `grantStore`, for apps without React.
|
|
263
|
+
|
|
264
|
+
1. The app makes its own key, kept in its own site's storage and never
|
|
265
|
+
exportable (`appKey()`).
|
|
266
|
+
2. The home opens in a popup. The person unlocks there — the account password
|
|
267
|
+
from their password manager, or a passkey — and picks which spaces the app
|
|
268
|
+
gets.
|
|
269
|
+
3. The home signs a note from the account to the app's key: these spaces, read
|
|
270
|
+
or change, for seven days. It hands the note back with invites for those
|
|
271
|
+
spaces, to the app's origin only.
|
|
272
|
+
4. The app's node signs with its own key under that note. It acts *for* the
|
|
273
|
+
account — records show the account as their author — but every peer checks
|
|
274
|
+
the note, so it cannot write anywhere it was not given.
|
|
275
|
+
|
|
276
|
+
What the note limits: **writing**, per space, checked by every peer. What it
|
|
277
|
+
cannot limit: **reading** a private space it was given — whoever holds a space's
|
|
278
|
+
key can read all of it, and that key does not change yet. Spaces an app wants
|
|
279
|
+
for itself are created by the home, as part of the approval, so they land in
|
|
280
|
+
the account's list on every device.
|
|
281
|
+
|
|
282
|
+
An app that is a view onto *everything* — like the example — asks for
|
|
283
|
+
`scope: 'account'`: a note for every space, plus the key the account's space
|
|
284
|
+
list is derived from, so it sees every space and can make and join them. It
|
|
285
|
+
still never holds the seed: it cannot sign in anywhere as the account, change
|
|
286
|
+
its password or passkeys, or keep access past the note's date.
|
|
287
|
+
|
|
288
|
+
The home side is `receiveConnectRequest()` and `auth.grant(…)`; see
|
|
289
|
+
[home/README.md](home/README.md).
|
|
290
|
+
|
|
291
|
+
## Modules
|
|
292
|
+
|
|
293
|
+
### Identity (`@weaveprotocol/core/identity`)
|
|
294
|
+
|
|
295
|
+
| Export | Description |
|
|
296
|
+
|--------|-------------|
|
|
297
|
+
| `generateSeed()` / `seedToRecoveryCode()` / `recoveryCodeToSeed()` | The account seed and its written form |
|
|
298
|
+
| `createIdentityManager()` | `fromSeed`, `fromRecoveryCode`, `fromPassword`; passkey-PRF derivation as an option |
|
|
299
|
+
| `createLocalRootSigner()` | A `RootSigner` for a seed unlocked in this page |
|
|
300
|
+
| `createFolderAccountStore()` / `createBrowserAccountStore()` | Where accounts live: a data folder, or this browser |
|
|
301
|
+
| `wrapSeedWithDeviceKey()` / `wrapSeedWithPassphrase()` | Local ways to unlock a stored seed |
|
|
302
|
+
| `deriveVaultKey()` | Key for sealing an account's space registry at rest |
|
|
303
|
+
| `pairingRoomId()` / `encodePairingTicket()` / `sealPairingPayload()` | Bringing a phone into an account |
|
|
304
|
+
| `issueUCAN()` / `verifyUCAN()` | Capability tokens (UCAN 0.10, `ES256` JWTs) |
|
|
305
|
+
| `delegateCapabilities()` | Attenuated delegation from a parent token |
|
|
306
|
+
| `validateDelegationChain()` | Verify a full root → … → leaf proof chain |
|
|
307
|
+
| `createP256Provider()` | ECDSA P-256 crypto provider (swappable) |
|
|
308
|
+
| `publicKeyToDid()` / `didToPublicKey()` | `did:key` encoding |
|
|
309
|
+
|
|
310
|
+
#### The account is a seed
|
|
311
|
+
|
|
312
|
+
An identity is 16 random bytes. HKDF-SHA256 stretches them to 48, `@noble/curves`
|
|
313
|
+
reduces those to a P-256 private key the standard way (FIPS 186-5, appendix
|
|
314
|
+
A.2), and the compressed public key becomes a spec-conformant `did:key`
|
|
315
|
+
(`did:key:zDn…`). The same seed always yields the same DID, and anything that
|
|
316
|
+
DID signs verifies for anyone who holds only the DID. Golden tests pin known
|
|
317
|
+
seeds to their DIDs, because a silent change here would give every account a
|
|
318
|
+
new identity.
|
|
319
|
+
|
|
320
|
+
The seed's written form is the **recovery code**: 128 bits in Crockford base32.
|
|
321
|
+
It is the primary way in, not a fallback. It is the only credential that works
|
|
322
|
+
on a domain that has never seen you, because there is nothing stored there for
|
|
323
|
+
anything else to unlock.
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
const code = generateRecoveryCode(); // 'K7N6-ERYP-68TZ-A7HN-VJW3-QWKN-CG'
|
|
327
|
+
const me = await createIdentityManager().fromRecoveryCode(code);
|
|
328
|
+
// case, spacing and the usual O/0, I/1 slips are all forgiven on the way back in
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
To a password manager the code is an ordinary generated password, so Bitwarden,
|
|
332
|
+
1Password, iCloud Keychain and the rest can store and autofill it. The example
|
|
333
|
+
app presents it in a username/password form for exactly that reason.
|
|
334
|
+
|
|
335
|
+
#### Unlocking on a device you've used before
|
|
336
|
+
|
|
337
|
+
Typing the code every visit would be tedious, so each origin can keep **wraps**:
|
|
338
|
+
encrypted copies of the seed, each opened a different way.
|
|
339
|
+
|
|
340
|
+
| Wrap | Opened by | Notes |
|
|
341
|
+
|---|---|---|
|
|
342
|
+
| `device` | A random, non-extractable key kept in this origin, with a passkey as the gate in front of it | Works with every passkey provider, because nothing is derived from the passkey |
|
|
343
|
+
| `passphrase` | PBKDF2-SHA256 → AES-GCM | A short password for this device |
|
|
344
|
+
|
|
345
|
+
See *Locking the folder* below for how wraps are stored.
|
|
346
|
+
|
|
347
|
+
#### Why passkeys are a gate, not the identity
|
|
348
|
+
|
|
349
|
+
A passkey can only hand an app a secret through the WebAuthn **PRF** extension.
|
|
350
|
+
Several major credential managers — Bitwarden and 1Password among them — store
|
|
351
|
+
passkeys without PRF, or report it inconsistently. An identity *derived* from a
|
|
352
|
+
passkey would lock those users out, and it would still be a different identity
|
|
353
|
+
on every domain, since a passkey is bound to one.
|
|
354
|
+
|
|
355
|
+
So the passkey only decides whether this origin may use its device key. PRF
|
|
356
|
+
derivation is still available (`identity.register()` / `authenticate()`, with
|
|
357
|
+
`inspectPasskeyPrf()` to diagnose what a provider actually does), but nothing in
|
|
358
|
+
the example depends on it.
|
|
359
|
+
|
|
360
|
+
#### UCAN delegation
|
|
361
|
+
|
|
362
|
+
A root identity — a passkey you never expose to a web app — delegates narrow,
|
|
363
|
+
expiring capabilities to keys that do the day-to-day signing:
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
import { issueUCAN, delegateCapabilities, validateDelegationChain } from '@weaveprotocol/core';
|
|
367
|
+
|
|
368
|
+
// Root grants a session key everything it may do with todos, for an hour
|
|
369
|
+
const sessionUcan = await issueUCAN({
|
|
370
|
+
issuer: { did: me.did, privateKey: me.privateKey },
|
|
371
|
+
audience: sessionDid,
|
|
372
|
+
capabilities: [{ with: 'space:app.example.todo', can: 'expression/*' }],
|
|
373
|
+
expiration: Math.floor(Date.now() / 1000) + 3600,
|
|
374
|
+
}, provider);
|
|
375
|
+
|
|
376
|
+
// The session key hands a guest a strictly weaker, read-only capability
|
|
377
|
+
const guestUcan = await delegateCapabilities({
|
|
378
|
+
parent: sessionUcan,
|
|
379
|
+
issuer: { did: sessionDid, privateKey: sessionKey },
|
|
380
|
+
audience: guestDid,
|
|
381
|
+
capabilities: [{ with: 'space:app.example.todo', can: 'expression/read' }],
|
|
382
|
+
}, provider);
|
|
383
|
+
|
|
384
|
+
// Any peer can check the whole chain back to the root DID
|
|
385
|
+
const chain = await validateDelegationChain(guestUcan.encoded, [sessionUcan.encoded], provider);
|
|
386
|
+
chain.valid; // true
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Escalation is refused at issue time (a child capability must be a subset of its
|
|
390
|
+
parent), a delegation can never outlive its parent, and only the audience of a
|
|
391
|
+
token may delegate it onward.
|
|
392
|
+
|
|
393
|
+
### Schema (`@weaveprotocol/core/schema`)
|
|
394
|
+
|
|
395
|
+
Typed, signed data expressions using [Standard Schema](https://standardschema.dev/).
|
|
396
|
+
|
|
397
|
+
| Export | Description |
|
|
398
|
+
|--------|-------------|
|
|
399
|
+
| `createSchemaEngine()` | Register collections with Standard Schema validators |
|
|
400
|
+
| `validateJsonSchema()` / `asStandardSchema()` | The JSON Schema a space stores, and its Standard Schema adapter |
|
|
401
|
+
| `createSigner()` | Sign and verify expressions (JWS-style) |
|
|
402
|
+
| `createExpression()` | Build unsigned expressions (optionally carrying a UCAN `proof`) |
|
|
403
|
+
| `canonicalize()` | Deterministic JSON serialization |
|
|
404
|
+
|
|
405
|
+
### Storage (`@weaveprotocol/core/storage`)
|
|
406
|
+
|
|
407
|
+
Local-first storage with Merkle Search Tree for efficient sync.
|
|
408
|
+
|
|
409
|
+
| Export | Description |
|
|
410
|
+
|--------|-------------|
|
|
411
|
+
| `createStorageProvider()` | MST-backed expression storage; `compact()` deletes tree nodes the root no longer reaches |
|
|
412
|
+
| `createIndexedDBAdapter()` | IndexedDB storage adapter, scoped to this origin |
|
|
413
|
+
| `createFolderAdapter()` | A user-picked directory, shared by every origin given access |
|
|
414
|
+
| `createEncryptedAdapter()` | Seals chosen keys (space records, space keys) at rest |
|
|
415
|
+
| `reconcileFolder()` | Rebuilds the tree after another writer touched a folder |
|
|
416
|
+
| `insertIntoMST()` / `listMSTEntries()` | Direct MST operations; a listing can stop at a key prefix |
|
|
417
|
+
|
|
418
|
+
### Spaces
|
|
419
|
+
|
|
420
|
+
A space is the container everything else lives in. It is **public** (signed in
|
|
421
|
+
the clear) or **private** (every body encrypted with the space key), and who
|
|
422
|
+
may write in it is decided by its **roles** — the space's own, not the
|
|
423
|
+
protocol's. A private notebook is a space whose creator never invited anyone;
|
|
424
|
+
a team list is one where everyone invited holds an Editor role.
|
|
425
|
+
|
|
426
|
+
| Export | Description |
|
|
427
|
+
|--------|-------------|
|
|
428
|
+
| `createSpaceManager()` | Create, list, join and forget spaces; mint invites |
|
|
429
|
+
| `parseSpaceInvite()` | Read an invite without joining, to show what it offers |
|
|
430
|
+
| `checkSpace()` / `spaceIdOf()` | Whether a space you were handed is the one its id names |
|
|
431
|
+
| `rolePresets` | Starting roles to use or ignore: `solo`, `team`, `community` |
|
|
432
|
+
| `replayAccess()` | The access history, replayed — who holds what, as of any point |
|
|
433
|
+
| `deriveInviteKey()` / `deriveReadKey()` | An invite link's key, and a private space's read key |
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
const spaces = createSpaceManager(adapter);
|
|
437
|
+
|
|
438
|
+
const { space, key } = await spaces.create({
|
|
439
|
+
name: 'Move house',
|
|
440
|
+
visibility: 'private', // key generated, bodies encrypted
|
|
441
|
+
creator: me.did,
|
|
442
|
+
...rolePresets.team, // Owner, and Editor for whoever is invited
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Give each space its own storage and its own MST and a peer you share one list
|
|
447
|
+
with learns nothing about the others.
|
|
448
|
+
|
|
449
|
+
#### Who may write: roles, and a history every peer replays
|
|
450
|
+
|
|
451
|
+
Three questions decide every write, each with its own mechanism:
|
|
452
|
+
|
|
453
|
+
1. **Who is really writing?** A note (UCAN): "this key speaks for this account."
|
|
454
|
+
2. **What standing does that account have here?** Its **role** in the space.
|
|
455
|
+
3. **Does this action on this record allow it?** The collection's **rules**.
|
|
456
|
+
|
|
457
|
+
A **role** is a name, a rank and a list of permissions. Three permissions are
|
|
458
|
+
the protocol's own — `manage` (roles and members), `invite` and `define`
|
|
459
|
+
(collections) — and every other one belongs to a collection (`app.poll/moderate`).
|
|
460
|
+
A `*` matches anything: `*` is every permission, `*/*` every collection's. The
|
|
461
|
+
**rank rule** is the only check rules cannot express: you may change people
|
|
462
|
+
and roles ranked below you, and give out roles up to your own rank. Two people
|
|
463
|
+
at the same rank can never remove each other, only themselves — so the creator
|
|
464
|
+
**hands over** by giving someone their role, then leaving, and the space goes on.
|
|
465
|
+
|
|
466
|
+
Roles, members, invites, revoked notes and collection definitions are records
|
|
467
|
+
(`sys.role`, `sys.member`, `sys.invite`, `sys.revoke`, `sys.collection`), and
|
|
468
|
+
every record written anywhere names the latest of them its writer knew, as
|
|
469
|
+
`seen`. That makes the **access history** a small graph, which every peer
|
|
470
|
+
replays the same way (`space/roles.ts`): a change comes after what it saw;
|
|
471
|
+
changes that did not see each other go taking-away first — counting everything
|
|
472
|
+
on the way to one — then the higher-ranked author, then the lower id; a change
|
|
473
|
+
counts only if its author had the power both as of what they saw and at its
|
|
474
|
+
turn. A record is judged by its author's role, and the definition in force, as
|
|
475
|
+
of its own `seen`.
|
|
476
|
+
|
|
477
|
+
**Taking access back.** Removing someone, lowering a role or closing an invite
|
|
478
|
+
carries a **keep list**: the records the remover had seen. A record that relied
|
|
479
|
+
on what was taken away, and had not seen it go, stands only if it is kept — so
|
|
480
|
+
claiming an old point in history, or an old date, gets a removed member
|
|
481
|
+
nothing, while what they wrote before stays. Apps connected through an account
|
|
482
|
+
home write under a note, never a secret of the space's; **Disconnect** writes a
|
|
483
|
+
`sys.revoke` for that note, and nothing under it counts from then on except
|
|
484
|
+
what the home had seen.
|
|
485
|
+
|
|
486
|
+
**Invites** are one per role. `node.spaces.invite(space)` opens one for the
|
|
487
|
+
lowest role below yours — or makes a view-only one when there is none — and
|
|
488
|
+
the link carries its secret (and a private space's key): shown once, kept
|
|
489
|
+
nowhere. The joiner writes their own member record, signed a second time by
|
|
490
|
+
the invite's key over the space and their identity; it counts once the
|
|
491
|
+
invite's record has reached them, so joining finishes on the first sync.
|
|
492
|
+
`closeInvite` takes the link itself.
|
|
493
|
+
|
|
494
|
+
Roles, members, invites and revokes stay **in the clear**, even in a private
|
|
495
|
+
space: a relay, a mirror or a host holding no secret of the space's replays the
|
|
496
|
+
same history and reaches the same verdict as a member, so a stranger who knows
|
|
497
|
+
a space's id cannot get a record stored anywhere. That shows who holds which
|
|
498
|
+
role — DIDs are on every signed record anyway. Collection definitions stay
|
|
499
|
+
sealed.
|
|
500
|
+
|
|
501
|
+
A private space also has a **read key**, derived from the space key, so everyone
|
|
502
|
+
who can read has it. Every connection — to an always-on node, or peer to peer
|
|
503
|
+
through a relay — starts with a handshake before anything else crosses it:
|
|
504
|
+
each side signs a fresh challenge with the key its DID names, so nobody can
|
|
505
|
+
connect under someone else's name, and in a private space with the read key
|
|
506
|
+
too, checked against its public half. A stranger who learns a space's id, or a
|
|
507
|
+
relay that sees its room, gets no ciphertext. A peer-to-peer handshake also
|
|
508
|
+
signs both ends' DTLS fingerprints, so a relay that swapped in its own offer to
|
|
509
|
+
sit in the middle is caught. Roles govern writing only: someone removed keeps
|
|
510
|
+
the read key until the space's key changes for everyone (BLOCK-14 §2).
|
|
511
|
+
|
|
512
|
+
A space's **id is the hash of what is fixed at creation**: creator, visibility,
|
|
513
|
+
starting roles and which one the creator holds, time, a random nonce and the
|
|
514
|
+
read key (the name is left out, so it can change). `join` refuses an invite
|
|
515
|
+
whose space does not hash to its id, or whose key is not the one the space
|
|
516
|
+
names — so whoever passes an invite on cannot change who started the space, or
|
|
517
|
+
with which roles.
|
|
518
|
+
|
|
519
|
+
**Spaces describe themselves.** A space stores its collections' definitions —
|
|
520
|
+
name, title, description and a JSON Schema — as signed records in
|
|
521
|
+
`sys.collection`, so an app or an agent that has never seen a space can ask what
|
|
522
|
+
it holds (`node.collections.list`) and what each thing looks like. Records are
|
|
523
|
+
checked against the definition when written, and flagged (`conforms`) when read;
|
|
524
|
+
nothing is refused during sync for its shape, so peers that saw definitions in
|
|
525
|
+
different orders still converge. `node.collections.define` publishes one — the
|
|
526
|
+
same call an agent makes through MCP.
|
|
527
|
+
|
|
528
|
+
**Links.** A record can point at another in a named role — `{ rel: 'about',
|
|
529
|
+
to: <key> }` — and `node.records.linked(space, key)` answers what points at a
|
|
530
|
+
thing. Links point at keys, so a comment stays on a post however often the post
|
|
531
|
+
is edited; in a private space they are sealed with the body, so a relay cannot
|
|
532
|
+
see what points at what. Collections declare their links in their definition,
|
|
533
|
+
so an agent reading `collections_list` sees how a space's things connect.
|
|
534
|
+
|
|
535
|
+
**Standard schemas, optional.** The protocol has no built-in kinds of record.
|
|
536
|
+
For the patterns nearly every app needs there is a small library of ordinary
|
|
537
|
+
collection definitions, named `std.*`, in `@weaveprotocol/core/schemas`: things that
|
|
538
|
+
attach to any record (`reaction`, `comment`, `tag`, `attachment`, `reference`)
|
|
539
|
+
and a few common nouns (`message`, `task`, `column`, `poll`, `vote`). Nouns kept in a hand-made
|
|
540
|
+
order carry a `position` string; `positionBetween(a, b)` makes one between two
|
|
541
|
+
neighbours, so moving a card rewrites only that card:
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
import { reaction, useSchemas } from '@weaveprotocol/core/schemas';
|
|
545
|
+
|
|
546
|
+
await useSchemas(node, space.id, [reaction]); // defines only what the space lacks
|
|
547
|
+
await node.records.put(space.id, reaction.name, { emoji: '👍' }, { links: [{ rel: 'about', to: post.key }] });
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Using the same ones is how two apps agree — reactions from one show up in the
|
|
551
|
+
other. The example app's Apps tab is built on this: a chat, a kanban board and
|
|
552
|
+
polls, each appearing in a space once it holds the collections it needs
|
|
553
|
+
(`std.message`; `std.task` and `std.column`; `std.poll` and `std.vote`). An app that wants its own shape defines its own collection instead.
|
|
554
|
+
|
|
555
|
+
**Rules, enforced by every peer.** A definition can say who may create, edit and
|
|
556
|
+
delete its records, what must be unique, and which fields are fixed:
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
await node.collections.define(space.id, {
|
|
560
|
+
name: 'app.poll.vote',
|
|
561
|
+
schema: voteSchema,
|
|
562
|
+
links: { about: { to: ['app.poll'], cardinality: 'one' } },
|
|
563
|
+
rules: { edit: 'creator', onePer: ['@author', 'link:about'] }, // one vote per person per poll
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`create`/`edit`/`delete` take `member` (anyone holding a role), `creator` — a
|
|
568
|
+
fact about the record, which nobody decides — or `can:<permission>`, naming a
|
|
569
|
+
permission the collection declares in `permissions`. The test for which: did a
|
|
570
|
+
person have to decide it? "The creator edits" follows from the data; "moderators
|
|
571
|
+
delete" needs `can:moderate`, and the space decides which roles hold
|
|
572
|
+
`app.poll/moderate`. `onePer` derives the record's key from what must be
|
|
573
|
+
unique, so voting again *is* changing your vote — no peer ever needs to see every
|
|
574
|
+
vote to stop a second one. `fixed` fields keep their first value. Each version
|
|
575
|
+
is judged by the definition in force as of the access history it saw, so every
|
|
576
|
+
peer judges it by the same rules: a forged edit or a second vote is refused
|
|
577
|
+
during sync, and something that arrives before what it depends on waits instead
|
|
578
|
+
of being guessed about. `node.records.can(space,
|
|
579
|
+
'edit', key)` asks first — for hiding a button rather than showing an error.
|
|
580
|
+
|
|
581
|
+
**Queries.** `node.records.query(space, { collection, where, include,
|
|
582
|
+
sort, limit, cursor })` finds records with Mongo-style filters (`{ done: false,
|
|
583
|
+
amount: { $gt: 10 } }`; `@author`, `@createdAt` and friends for the record
|
|
584
|
+
itself) and pulls in what links to them — `include: { likes: { rel: 'about',
|
|
585
|
+
from: 'std.reaction', count: true } }`. A query is plain JSON, so an agent
|
|
586
|
+
sends the same thing over `records_query`; `node.records.watch` re-runs one as
|
|
587
|
+
records sync in. Only records this device can read come back, so `body` is
|
|
588
|
+
never null.
|
|
589
|
+
|
|
590
|
+
**Typed queries.** Name a collection by its definition instead of a string, and
|
|
591
|
+
the results are typed from its schema — including everything `include` pulls
|
|
592
|
+
in, and `number` for a `count`. `collection()` keeps a definition's types; the
|
|
593
|
+
standard schemas already carry theirs; `Typed<T>` names a collection whose
|
|
594
|
+
schema is plain JSON Schema. Before a query runs, every reference becomes its
|
|
595
|
+
name, so the query is still plain data.
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
import { collection } from '@weaveprotocol/core';
|
|
599
|
+
|
|
600
|
+
const polls = collection({ name: 'app.poll', schema: Poll }); // Poll is a Zod object
|
|
601
|
+
const votes = collection({ name: 'app.poll.vote', schema: Vote });
|
|
602
|
+
|
|
603
|
+
await node.records.put(space.id, polls, { question: 'Where?', options: ['Oslo', 'Lisbon'] }); // checked against Poll
|
|
604
|
+
|
|
605
|
+
const { records } = await node.records.query(space.id, {
|
|
606
|
+
collection: polls,
|
|
607
|
+
include: { votes: { rel: 'about', from: votes } },
|
|
608
|
+
});
|
|
609
|
+
records[0].body.question; // string
|
|
610
|
+
records[0].included.votes[0].body.choice; // number
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
**The account registry.** Which spaces an account belongs to is itself kept in
|
|
614
|
+
a space: a private one whose id and key are derived from the account's vault
|
|
615
|
+
key, so every device of the account finds it and nobody else can. Creating or
|
|
616
|
+
joining a space writes a membership record there (carrying the invite, so the
|
|
617
|
+
key too); every other device and node of the account syncs it and joins by
|
|
618
|
+
itself. A membership deleted on any device means the account left, and every device leaves.
|
|
619
|
+
Pass `accountKey` to `createNode` to turn it on. The account's name lives there
|
|
620
|
+
too (`node.account.setName`), so a rename on one device or site reaches every
|
|
621
|
+
other one — and a site opening the account for the first time shows its name.
|
|
622
|
+
|
|
623
|
+
**Profiles.** Other people see you by that name. The node publishes it into
|
|
624
|
+
every space it opens, and again on a rename, as a `sys.profile` record keyed by
|
|
625
|
+
a hash of your identity; `node.spaces.profiles(space)` (and the
|
|
626
|
+
`spaces_profiles` action) says who is who. Every version is kept and the one
|
|
627
|
+
shown is the newest signed by the identity the key names, so nobody can rename
|
|
628
|
+
anyone else. Someone following a space without a role publishes nothing there.
|
|
629
|
+
|
|
630
|
+
**Moving and merging.** `copyAccountData` copies an account's spaces, keys and
|
|
631
|
+
records from one set of stores to another — out of a browser's own database into
|
|
632
|
+
a data folder, for instance. Because every record is signed, named by its
|
|
633
|
+
content, and deletes are records too, merging into a folder that already holds
|
|
634
|
+
the same account is the same operation: the result is everything from both, and
|
|
635
|
+
whatever either side deleted stays deleted. A space lives on the devices that hold it,
|
|
636
|
+
not inside the identity — bringing a DID back on a new device restores who you
|
|
637
|
+
are, and an invite (even one you send yourself) restores what you had. Expressions name their space in a signed
|
|
638
|
+
field, which stops one being replayed into another.
|
|
639
|
+
|
|
640
|
+
**Encrypt, then sign.** A private space encrypts the body *before* the expression
|
|
641
|
+
is signed, so the signature covers the ciphertext: peers without the key still
|
|
642
|
+
verify and relay the data, they simply cannot read it. The structural gate steps
|
|
643
|
+
aside for encrypted bodies — their shape is checked by members after decryption.
|
|
644
|
+
|
|
645
|
+
### Network (`@weaveprotocol/core/network`)
|
|
646
|
+
|
|
647
|
+
Browser-to-browser communication via WebRTC.
|
|
648
|
+
|
|
649
|
+
| Export | Description |
|
|
650
|
+
|--------|-------------|
|
|
651
|
+
| `createMesh()` | One node's WebRTC connections through relays, shared by every space: `mesh.join(room, auth)` gives a space its peers |
|
|
652
|
+
| `createNetworkManager()` | Peers over a transport that dials on its own — a node's socket, a local link |
|
|
653
|
+
| `createSignalingClient()` | WebSocket signaling for ICE/SDP exchange |
|
|
654
|
+
| `createMultiSignalingClient()` | Several relays used at once, de-duplicated |
|
|
655
|
+
| `createRTCTransport()` | WebRTC data channel management (the default transport) |
|
|
656
|
+
| `createWebSocketTransport()` | A socket to one always-on node — no relay, no TURN |
|
|
657
|
+
| `createMeshAuth()` | The peer-to-peer handshake: each side proves its DID, and in a private space that it may read |
|
|
658
|
+
| `createClientAuth()` / `createServerAuth()` | The handshake with a node: the client proves its DID (and the read key, if private), the node signs with its own |
|
|
659
|
+
|
|
660
|
+
#### Signaling relay
|
|
661
|
+
|
|
662
|
+
`server/signaling-server.mjs` is a dumb relay in a couple hundred lines of
|
|
663
|
+
Node on the `ws` library: a peer holds one socket and joins a room on it for
|
|
664
|
+
each space, and the relay passes join notices and WebRTC offers, answers and
|
|
665
|
+
candidates between peers that share a room. (A socket opened with `?room=` is
|
|
666
|
+
the older one-room form, still served.) The room is a hash of
|
|
667
|
+
the space's id (`relayRoom`), so the relay cannot tell which space a room is.
|
|
668
|
+
Expression data never touches it — that flows peer to peer — and it cannot
|
|
669
|
+
read a private space.
|
|
670
|
+
|
|
671
|
+
It is open to anyone, so it keeps to limits: small messages, a cap on
|
|
672
|
+
connections per address, peers per room and rooms per socket, a message rate
|
|
673
|
+
per socket, and one DID per socket, which cannot be claimed twice in a room. Every message
|
|
674
|
+
it forwards carries the sender's DID as it joined, whatever the message says.
|
|
675
|
+
|
|
676
|
+
```bash
|
|
677
|
+
npm run signal # ws://localhost:8787; /health says {"ok":true}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Only peers already in a room hear about a newcomer, so exactly one side creates
|
|
681
|
+
the offer and the two never collide.
|
|
682
|
+
|
|
683
|
+
### Sync (`@weaveprotocol/core/sync`)
|
|
684
|
+
|
|
685
|
+
Anti-entropy gossip protocol for eventual consistency.
|
|
686
|
+
|
|
687
|
+
| Export | Description |
|
|
688
|
+
|--------|-------------|
|
|
689
|
+
| `createSyncEngine()` | Automatic MST reconciliation with heartbeat |
|
|
690
|
+
| `verifyNode()` / `unknownChildren()` | The pieces of a tree walk |
|
|
691
|
+
|
|
692
|
+
Two peers compare roots — equal means identical, one round trip. Otherwise each
|
|
693
|
+
walks the other's tree from the root, skipping every subtree already in its own,
|
|
694
|
+
so cost follows the size of the difference: one changed entry in 10,000 costs
|
|
695
|
+
about 37 KB on the wire, where sending every key cost 508 KB. Whether a subtree
|
|
696
|
+
is already here is one lookup in the store, not a read of the whole local tree.
|
|
697
|
+
|
|
698
|
+
The engine's `validate` hook is the seam where the validation engine sits.
|
|
699
|
+
Expressions a peer sends are only committed if it accepts them; the rest are
|
|
700
|
+
dropped and surface as a `rejected` event with the reason.
|
|
701
|
+
|
|
702
|
+
### Validation (`@weaveprotocol/core/validation`)
|
|
703
|
+
|
|
704
|
+
A pipeline of gates for incoming expressions.
|
|
705
|
+
|
|
706
|
+
| Export | Description |
|
|
707
|
+
|--------|-------------|
|
|
708
|
+
| `createValidationEngine()` | Full gatekeeper pipeline |
|
|
709
|
+
| `createCryptoGate()` | Expression id + signature verification |
|
|
710
|
+
| `createStructuralGate()` | Schema conformance via Standard Schema |
|
|
711
|
+
| `createCapabilityGate()` | UCAN authorization: may this key write this? |
|
|
712
|
+
| `createStatefulGate()` | Custom Wasm rules |
|
|
713
|
+
|
|
714
|
+
The crypto gate settles *who* signed an expression. The capability gate answers
|
|
715
|
+
the next question: were they allowed to? An expression signed by a delegated key
|
|
716
|
+
carries its UCAN in `proof` — a signed field, so it cannot be swapped out — and
|
|
717
|
+
the gate walks that chain back to a root identity, rejecting anything expired,
|
|
718
|
+
issued to a different key, broader than its parent, or rooted in an identity the
|
|
719
|
+
application does not trust.
|
|
720
|
+
|
|
721
|
+
```typescript
|
|
722
|
+
const validation = createValidationEngine({
|
|
723
|
+
cryptoGate: createCryptoGate(provider),
|
|
724
|
+
structuralGate: createStructuralGate(schema),
|
|
725
|
+
statefulGate: createStatefulGate(),
|
|
726
|
+
capabilityGate: createCapabilityGate({
|
|
727
|
+
provider,
|
|
728
|
+
requiredCapability: (expression) => ({ with: `space:${expression.collection}`, can: 'expression/write' }),
|
|
729
|
+
isTrustedRoot: (did) => spaceMembers.has(did),
|
|
730
|
+
}),
|
|
731
|
+
resolvePublicKey: async (did) => provider.importPublicKey(didToPublicKey(did).publicKeyBytes),
|
|
732
|
+
getExpression: (id) => storage.getExpression(id),
|
|
733
|
+
});
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### Privacy (`@weaveprotocol/core/privacy`)
|
|
737
|
+
|
|
738
|
+
End-to-end encryption for private Spaces.
|
|
739
|
+
|
|
740
|
+
| Export | Description |
|
|
741
|
+
|--------|-------------|
|
|
742
|
+
| `createPrivacyGuard()` | Transparent E2EE orchestrator |
|
|
743
|
+
| `generateSpaceKey()` | AES-GCM-256 space keys |
|
|
744
|
+
| `wrapSpaceKey()` | ECDH + AES-KW key distribution |
|
|
745
|
+
|
|
746
|
+
## Storage Adapters
|
|
747
|
+
|
|
748
|
+
The protocol uses an adapter pattern for storage flexibility:
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
interface StorageAdapter {
|
|
752
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
753
|
+
put(key: string, value: Uint8Array): Promise<void>;
|
|
754
|
+
delete(key: string): Promise<void>;
|
|
755
|
+
putExpression(expression: Expression): Promise<void>;
|
|
756
|
+
getExpression(id: string): Promise<Expression | null>;
|
|
757
|
+
deleteExpression(id: string): Promise<void>;
|
|
758
|
+
queryExpressions(collection: string, limit?: number): Promise<Expression[]>;
|
|
759
|
+
// ... more
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
**Built-in**:
|
|
764
|
+
|
|
765
|
+
- `createIndexedDBAdapter(name)` — works in every browser. Origin-scoped.
|
|
766
|
+
- `createFolderAdapter(directory, namespace)` — a directory the user picked, via the File System Access API. **Not** origin-scoped. Chrome, Edge and Opera on the desktop.
|
|
767
|
+
|
|
768
|
+
The always-on node (`weave run`) uses the folder adapter on disk, in the same layout. **Planned**: mirrors, which keep a space in storage the user already pays for (a Dropbox app folder, Drive, S3) and sync with it like a peer — see `docs/blocks/BLOCK-03-mirrors.md`. OPFS is not on the list: it is origin-private, so it would inherit exactly the limitation a data folder exists to avoid.
|
|
769
|
+
|
|
770
|
+
### Data folders — storage that outlives the origin
|
|
771
|
+
|
|
772
|
+
(The app calls a data folder a **pod**.)
|
|
773
|
+
|
|
774
|
+
Every in-browser store is keyed by origin. IndexedDB, localStorage, Cache API and OPFS (the name is the spec: *Origin Private* File System) all partition by it, so two deployments of one app on two domains can never read each other's data, and a passkey — bound to an RP ID, which is a domain — derives a different identity on each. Two views of the same app become two unrelated accounts.
|
|
775
|
+
|
|
776
|
+
A directory handle is the exception. Each origin asks for permission once, and both end up looking at the same files:
|
|
777
|
+
|
|
778
|
+
```
|
|
779
|
+
<folder>/
|
|
780
|
+
accounts.json name, DID and id of each account (readable without unlocking)
|
|
781
|
+
accounts/<id>/account.json that account's seed, encrypted once per way of unlocking it
|
|
782
|
+
accounts/<id>/stores/<namespace>/
|
|
783
|
+
kv/<key> MST nodes, the root pointer, space records (sealed)
|
|
784
|
+
expressions/<cid>.json one signed record per file
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
A folder is a disk, not a person: several accounts can live in one. A browser
|
|
788
|
+
with no folder keeps the same shape in IndexedDB, so an app has one model
|
|
789
|
+
rather than two.
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
import {
|
|
793
|
+
pickDataFolder, createFolderAccountStore, recoveryCodeToSeed, deriveVaultKey,
|
|
794
|
+
createFolderAdapter, createEncryptedAdapter, reconcileFolder,
|
|
795
|
+
createIdentityManager, createStorageProvider,
|
|
796
|
+
} from '@weaveprotocol/core';
|
|
797
|
+
|
|
798
|
+
const folder = await pickDataFolder(); // needs a user gesture
|
|
799
|
+
const accounts = createFolderAccountStore(folder);
|
|
800
|
+
const [account] = await accounts.list(); // names and DIDs; nothing unlocked yet
|
|
801
|
+
|
|
802
|
+
const seed = recoveryCodeToSeed(code); // or open one of its wraps, below
|
|
803
|
+
const identity = await createIdentityManager().fromSeed(seed);
|
|
804
|
+
|
|
805
|
+
const adapter = await createFolderAdapter(folder, `${account.dataPath}/spaces/${spaceId}`);
|
|
806
|
+
const storage = createStorageProvider(adapter);
|
|
807
|
+
await reconcileFolder(storage, adapter); // pick up other writers
|
|
808
|
+
|
|
809
|
+
// The registry is sealed under a key only an unlocked folder can derive.
|
|
810
|
+
const registry = createEncryptedAdapter(
|
|
811
|
+
await createFolderAdapter(folder, `${account.dataPath}/registry`),
|
|
812
|
+
await deriveVaultKey(seed),
|
|
813
|
+
);
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
**Expressions are the truth; the MST is an index over them.** That inversion is what lets several writers share one folder without taking a lock. Every expression file is named by its own content hash, so concurrent writers can only ever add files that agree; the single mutable thing, the root pointer, is derived state that either side can rebuild. `reconcileFolder()` rebuilds it — call it on an interval, on window focus, or after a sync round, since the web has no filesystem change notification.
|
|
817
|
+
|
|
818
|
+
Two consequences worth having:
|
|
819
|
+
|
|
820
|
+
- **The folder is the account.** Copy it to a USB stick and it is your whole identity. Put it in iCloud, Dropbox or Syncthing and several devices converge with no relay at all — the folder becomes a second transport alongside WebRTC, and both meet in the same anti-entropy merge.
|
|
821
|
+
- **It changes nothing about the mesh.** A folder-backed node is an ordinary peer that happens to be durable and readable by several origins — an availability role, never an authority one. Where there is no folder (Safari, Firefox, mobile) a node keeps an origin-scoped replica and gossips exactly as before.
|
|
822
|
+
|
|
823
|
+
### Locking the folder
|
|
824
|
+
|
|
825
|
+
A folder whose account file held the seed in the clear would be a bearer token — copying it would be enough to become its owner, and the AES key for every private space sits in the same directory as the ciphertext it opens. So the seed is never stored. Each `account.json` holds **wrapped copies** of it, one per way of unlocking:
|
|
826
|
+
|
|
827
|
+
```
|
|
828
|
+
account seed (16 bytes, never written in the clear)
|
|
829
|
+
├── HKDF → vault key ──encrypts──> space keys and space records at rest
|
|
830
|
+
└── stored only as wraps:
|
|
831
|
+
device a random local key, gated by any passkey (one per origin)
|
|
832
|
+
passphrase PBKDF2-SHA256 → AES-GCM
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
A device wrap is opened by a random key kept in one origin's storage, with a passkey as the gate in front of it — **not** derived from the passkey (see *Why passkeys are a gate, not the identity*). So every provider works, and each origin adds a wrap of its own:
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
const deviceKey = await createDeviceKey(); // non-extractable, local
|
|
839
|
+
const wrap = await wrapSeedWithDeviceKey(seed, deviceKey, { rpId, credentialId });
|
|
840
|
+
await accounts.write(account, withWrap(vault, wrap));
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
`deviceWrapsFor(vault, rpId)` says which wraps this origin can even attempt; the rest name keys it cannot reach. The gate is enforced in application code rather than by cryptography — see `src/identity/device-key.ts` for what that does and does not protect against. The recovery code needs no wrap, because it *is* the seed in printable form — it opens the folder anywhere, including on a phone or in a browser with no File System Access API, and it is shown once and stored nowhere.
|
|
844
|
+
|
|
845
|
+
`createEncryptedAdapter` seals `space:`, `spacekey:`, `spaceinvite:` and `spacerole:` values under the vault key, which is what makes a private space genuinely unreadable to someone holding the folder. It is scoped deliberately narrowly: expressions and MST nodes pass through, so what stays legible is each record's author, timestamp and collection, plus anything in a space its owner made public. Sealing those too would mean an opaque blob store, which would cost the property that makes a folder worth having.
|
|
846
|
+
|
|
847
|
+
### Where the root key lives
|
|
848
|
+
|
|
849
|
+
The root key signs exactly one thing: a note saying a session key may write for
|
|
850
|
+
the next hour. Everything else is signed by the session key. That one signature
|
|
851
|
+
is the only reason an app needs the identity — so it is the only thing that has
|
|
852
|
+
to move for the key to live somewhere else:
|
|
853
|
+
|
|
854
|
+
```typescript
|
|
855
|
+
export interface RootSigner {
|
|
856
|
+
readonly did: string;
|
|
857
|
+
readonly custody: 'local' | 'remote';
|
|
858
|
+
delegate(params: { audience, capabilities, expiration }): Promise<UCANToken>;
|
|
859
|
+
}
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
`createLocalRootSigner` signs in the page, for a seed unlocked here. Anything
|
|
863
|
+
else that holds the key can implement the same interface and sign where it is,
|
|
864
|
+
so the seed never crosses into the page. Nothing downstream changes either
|
|
865
|
+
way, because DIDs, expressions, validation and sync never see the root key
|
|
866
|
+
under any arrangement.
|
|
867
|
+
|
|
868
|
+
### Meeting peers
|
|
869
|
+
|
|
870
|
+
Two browsers cannot find each other unaided — neither can accept an incoming
|
|
871
|
+
connection — so something has to make the introduction. That something is a
|
|
872
|
+
relay, and it is worth being precise about how little it is: it forwards
|
|
873
|
+
connection offers, never sees an expression, and drops out of the conversation
|
|
874
|
+
the moment two peers are talking.
|
|
875
|
+
|
|
876
|
+
Two things keep it from being an authority:
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
const mesh = createMesh({
|
|
880
|
+
// Used all at once, not as failover: two people who picked different relays
|
|
881
|
+
// would otherwise never meet.
|
|
882
|
+
relays: ['wss://relay-a.example', 'wss://relay-b.example'],
|
|
883
|
+
did: sessionDid,
|
|
884
|
+
introductions: true, // the default
|
|
885
|
+
});
|
|
886
|
+
const peers = mesh.join(await relayRoom(spaceId), createMeshAuth(spaceId, session, read, provider));
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
**One connection per pair of devices**, however many spaces they share: one
|
|
890
|
+
socket per relay, one WebRTC connection per peer, and each space proves itself
|
|
891
|
+
on it separately before any of its data crosses — so a peer you share one
|
|
892
|
+
space with is a peer in that one only.
|
|
893
|
+
|
|
894
|
+
**Several relays**, so there is no single phone book — a peer announced by two
|
|
895
|
+
of them is announced upward once, and replies go back the way they arrived.
|
|
896
|
+
|
|
897
|
+
**Peers introduce peers**, so a relay is only needed for the *first* connection.
|
|
898
|
+
Once you are connected to someone, their data channel carries signalling for the
|
|
899
|
+
peers you have not met: `__peers` says who I can see, `__signal` carries
|
|
900
|
+
somebody else's offer onward, bounded by a hop count and de-duplicated by id.
|
|
901
|
+
Both sides of a new pair learn of each other at once, so the lower identifier
|
|
902
|
+
offers and the other waits — otherwise every introduction would open two
|
|
903
|
+
connections. After that the mesh introduces itself and the relay can go away.
|
|
904
|
+
|
|
905
|
+
`server/` has a Dockerfile and a `fly.toml` for running one.
|
|
906
|
+
|
|
907
|
+
### Pairing a phone
|
|
908
|
+
|
|
909
|
+
No mobile browser has the File System Access API, so a phone keeps its own
|
|
910
|
+
replica like any other peer. Getting it started takes two things — the identity,
|
|
911
|
+
and the list of spaces — and only the first fits in a QR code:
|
|
912
|
+
|
|
913
|
+
```typescript
|
|
914
|
+
import {
|
|
915
|
+
pairingRoomId, derivePairingKey, encodePairingTicket,
|
|
916
|
+
sealPairingPayload, openPairingPayload,
|
|
917
|
+
} from '@weaveprotocol/core';
|
|
918
|
+
|
|
919
|
+
// Desktop: a link for the QR. The fragment never reaches a server.
|
|
920
|
+
const ticket = encodePairingTicket({ v: 1, code: seedToRecoveryCode(seed), relay });
|
|
921
|
+
const url = `${origin}${pathname}#pair=${ticket}`;
|
|
922
|
+
|
|
923
|
+
// Both sides, independently — no negotiation, nothing sent.
|
|
924
|
+
const room = await pairingRoomId(seed);
|
|
925
|
+
const key = await derivePairingKey(seed);
|
|
926
|
+
|
|
927
|
+
// Desktop, once the phone turns up in that room:
|
|
928
|
+
send(await sealPairingPayload(utf8Encode(JSON.stringify({ spaces: invites })), key));
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
The room is derived from the **seed**, not the DID. A DID appears in every
|
|
932
|
+
expression an account has ever signed, so a room named after one could be found
|
|
933
|
+
by anyone who had seen its data; a room named after the seed can only be found by
|
|
934
|
+
someone who already has it.
|
|
935
|
+
|
|
936
|
+
Encoding a URL rather than raw data means phone cameras open it natively — no
|
|
937
|
+
scanner, and it works on iOS. Afterwards the phone is a full peer that syncs with
|
|
938
|
+
anyone in the space, not a satellite of the machine that paired it.
|
|
939
|
+
|
|
940
|
+
## Swappable Crypto
|
|
941
|
+
|
|
942
|
+
The `CryptoProvider` interface abstracts key algorithms:
|
|
943
|
+
|
|
944
|
+
```typescript
|
|
945
|
+
// Default: ECDSA P-256
|
|
946
|
+
const provider = createP256Provider();
|
|
947
|
+
|
|
948
|
+
// Future: Ed25519, etc.
|
|
949
|
+
const identity = createIdentityManager({ provider: myEd25519Provider });
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
## Standard Schema Integration
|
|
953
|
+
|
|
954
|
+
A space stores its collections' shapes as JSON Schema, so any app in any
|
|
955
|
+
language can read them. You don't have to write it by hand: pass a validator
|
|
956
|
+
that can describe itself as JSON Schema — [Standard JSON
|
|
957
|
+
Schema](https://standardschema.dev/json-schema): Zod 4.2+, ArkType 2.1.28+,
|
|
958
|
+
Valibot through `toStandardJsonSchema` — and the node stores what it accepts.
|
|
959
|
+
|
|
960
|
+
```typescript
|
|
961
|
+
import * as z from 'zod';
|
|
962
|
+
|
|
963
|
+
const Poll = z.object({
|
|
964
|
+
question: z.string().min(1).max(500),
|
|
965
|
+
options: z.array(z.string().min(1)).min(2).max(10),
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
await node.collections.define(space.id, { name: 'app.poll', schema: Poll });
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
A space can store only a small subset of JSON Schema, the part every language
|
|
972
|
+
agrees on (types, required, enums, lengths and sizes, number bounds, labelled
|
|
973
|
+
choices). A validator feature with no stored equivalent — `z.email()`, whose
|
|
974
|
+
check is a regex — is refused when you define the collection, saying what is
|
|
975
|
+
supported, rather than quietly not being enforced by other apps.
|
|
976
|
+
|
|
977
|
+
Lower down, the schema engine takes any [Standard Schema
|
|
978
|
+
v1](https://standardschema.dev/) validator directly, for local checks:
|
|
979
|
+
|
|
980
|
+
```typescript
|
|
981
|
+
import { createSchemaEngine } from '@weaveprotocol/core';
|
|
982
|
+
|
|
983
|
+
const schema = createSchemaEngine();
|
|
984
|
+
schema.registerCollection({ name: 'app.example.post', schema: PostSchema });
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
## Command line, always-on node, and agents
|
|
988
|
+
|
|
989
|
+
`cli/` is `weave`: every node operation from a terminal, `weave run` to keep an
|
|
990
|
+
account's spaces syncing on a server (browsers connect to it over WebSocket, and
|
|
991
|
+
it doubles as a relay), and `weave mcp` to hand the same operations to an agent.
|
|
992
|
+
It reads and writes the same data folder layout a browser does. See
|
|
993
|
+
[cli/README.md](cli/README.md).
|
|
994
|
+
|
|
995
|
+
## Example app
|
|
996
|
+
|
|
997
|
+
`example/` is the Weave website — a landing page for developers at `/`, and
|
|
998
|
+
why Weave, for people, at `/why` — and, at `/app`, a general-purpose app for your spaces — Vite + React, consuming
|
|
999
|
+
the protocol straight from `src/`. It knows no kinds of data in advance: every
|
|
1000
|
+
screen is worked out from what a space says about itself (see *Derived UI* below):
|
|
1001
|
+
|
|
1002
|
+
```bash
|
|
1003
|
+
npm install && (cd example && npm install) && (cd home && npm install) && (cd cli && npm install)
|
|
1004
|
+
npm run dev
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
That starts three things: the example app on 5173, the account home it
|
|
1008
|
+
connects to on 5174, and an always-on node on port 8787 that is also the relay.
|
|
1009
|
+
The node gets a throwaway identity on first run (`cli/.env.dev`, data in
|
|
1010
|
+
`.weave-dev/`), and `example/.env.development` points the app at the other two.
|
|
1011
|
+
Override either in a `.env.local`.
|
|
1012
|
+
|
|
1013
|
+
The example never signs anyone in: "Connect with Weave" opens the home, where
|
|
1014
|
+
you make an account or sign in, and allow the example your whole account. Its
|
|
1015
|
+
avatar menu opens the home for account settings.
|
|
1016
|
+
|
|
1017
|
+
To give the node a space, create an invite link in the app and:
|
|
1018
|
+
|
|
1019
|
+
```bash
|
|
1020
|
+
npm run weave -- spaces join --invite '<link>'
|
|
1021
|
+
npm run weave -- records list --space <id>
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Close every browser holding the space, open the link somewhere else, and the
|
|
1025
|
+
records come from the node. Or make the node your own account's — see
|
|
1026
|
+
[cli/README.md](cli/README.md) — and it serves every space you make, unasked.
|
|
1027
|
+
|
|
1028
|
+
Between them they exercise the stack end to end. At the home: choose where
|
|
1029
|
+
your data lives (a pod, or this browser), create an account (a password your
|
|
1030
|
+
password manager keeps) or sign in to one, stay signed in, add a passkey, move
|
|
1031
|
+
between pods, pair a phone by QR code, and see which apps you connected. In the
|
|
1032
|
+
example: make private or public spaces, just yours or with people you invite, and share one with a
|
|
1033
|
+
friend via an invite link. Everyone in a space is shown by the name
|
|
1034
|
+
they gave. Every record is signed by a delegated session key, stored in that
|
|
1035
|
+
space's MST, encrypted first if the space is private, and gossiped to peers over
|
|
1036
|
+
WebRTC; a record says *verified* once its signature and its delegation chain
|
|
1037
|
+
check out here, and *encrypted* when it arrived encrypted.
|
|
1038
|
+
|
|
1039
|
+
**Derived UI.** A space opens on its **Apps** tab: apps built on the standard
|
|
1040
|
+
schemas (a chat, a kanban board) show up once the space holds the collections
|
|
1041
|
+
they need, and adding one defines what is missing. The **Collections** tab
|
|
1042
|
+
lists every collection down the side — the space's catalogue. Each one is a list you can search and add to in one line, or a
|
|
1043
|
+
table, or — when it has a field with fixed choices — a board you drag cards
|
|
1044
|
+
across; a yes/no field becomes a checkbox on each row. A record opens in a
|
|
1045
|
+
panel beside the list: its fields as properties you edit in place, what it
|
|
1046
|
+
points at and what points at it, and reactions, tags and comments, which get a place on every record once the
|
|
1047
|
+
space has added them from the library. And there is a "+ Add …" button for every collection
|
|
1048
|
+
that declares a link to this collection: define `app.poll.vote` with
|
|
1049
|
+
`about → app.poll` and every poll gets "+ Add vote". Choices show by their label: `oneOf: [{ const, title }]`
|
|
1050
|
+
for fixed ones, and `x-choicesFrom: { rel: 'about', field: 'options' }` for a
|
|
1051
|
+
field that picks from a list in the linked record — so a vote stored as `1`
|
|
1052
|
+
shows as "Lisbon", its form offers the poll's options, and the poll shows a
|
|
1053
|
+
tally. The helpers that work this out are pure functions
|
|
1054
|
+
(`example/src/derive/schema-ui.ts`), with nothing DOM-specific in them. An
|
|
1055
|
+
empty space offers a small "define a collection" form; an agent can do the
|
|
1056
|
+
same over WebMCP.
|
|
1057
|
+
|
|
1058
|
+
**Agents in the browser (WebMCP).** When `/app` loads, it registers
|
|
1059
|
+
every node operation as a WebMCP tool on `document.modelContext`
|
|
1060
|
+
(`example/src/webmcp.ts`, with `@mcp-b/webmcp-polyfill`: Chrome's own WebMCP
|
|
1061
|
+
when present, a polyfill otherwise). A browser agent or extension sees the same
|
|
1062
|
+
tools as the CLI and `weave mcp` — `spaces_list`, `records_query`,
|
|
1063
|
+
`records_put`, `apps_propose`… — and works as the person, with nothing to
|
|
1064
|
+
switch on: anything that can call a page's tools can already click through the
|
|
1065
|
+
page, so a key of its own would stop nothing. It isn't offered
|
|
1066
|
+
`collections_define`: it proposes apps (`apps_propose`) and a person adds
|
|
1067
|
+
them. Anything that changes a space's people, or hands out its key, asks the
|
|
1068
|
+
person first. An app may bring its own screen: a collection definition's
|
|
1069
|
+
`screen`, one HTML document, which the example runs in a sandboxed frame with
|
|
1070
|
+
no network, talking to the space only through a message port
|
|
1071
|
+
(`createScreenBridge`, `apps_screen_guide`). `docs/screens/chess.html` is one
|
|
1072
|
+
an agent wrote.
|
|
1073
|
+
|
|
1074
|
+
**Agents on your computer (Claude Code, Claude Desktop, Cursor).** "Connect an
|
|
1075
|
+
agent", in the account menu, shows one command:
|
|
1076
|
+
`npx @weaveprotocol/cli connect wv_…`. The terminal makes its own key, finds
|
|
1077
|
+
the tab through the relay, and the person allows it at their account home,
|
|
1078
|
+
which signs an agent's note for the whole account, for as long as they chose.
|
|
1079
|
+
Everything said on the way is sealed with a key from the code, so the relay
|
|
1080
|
+
learns nothing (`src/session/agent-link.ts`). The command then adds `weave` to
|
|
1081
|
+
the agents it finds, and they start `weave mcp` themselves: a node of its own,
|
|
1082
|
+
over WebRTC (`node-datachannel`), that follows the account and keeps working
|
|
1083
|
+
with every tab closed. What it writes shows "via agent", and every peer
|
|
1084
|
+
ignores an agent changing collections, who may do what, or the account's own
|
|
1085
|
+
list of spaces. See BLOCK-20.
|
|
1086
|
+
|
|
1087
|
+
## Tests
|
|
1088
|
+
|
|
1089
|
+
```bash
|
|
1090
|
+
npm test
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
Covers key derivation — checked against the public keys Web Crypto generates
|
|
1094
|
+
for the same private scalars, and pinned to recorded DIDs so an accidental
|
|
1095
|
+
change cannot slip through; recovery codes; account vaults, wraps and account
|
|
1096
|
+
stores; data folders with several writers; spaces, invites and
|
|
1097
|
+
encrypt-then-sign; UCAN issuing, attenuation and chain validation; phone
|
|
1098
|
+
pairing; peer introductions; the MST; the validation gates; and two peers
|
|
1099
|
+
reconciling over the anti-entropy protocol, including the forged, stolen,
|
|
1100
|
+
unauthorized and malformed expressions their gatekeepers reject. Above those:
|
|
1101
|
+
the node API — versioned records, links, queries, collection definitions,
|
|
1102
|
+
profiles, the account registry, moving and merging accounts — and the CLI.
|
|
1103
|
+
|
|
1104
|
+
## License
|
|
1105
|
+
|
|
1106
|
+
MIT
|