@metalabel/dfos-web-relay 0.4.0 → 0.5.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 +74 -1
- package/RELAY.md +269 -26
- package/dist/index.d.ts +61 -8
- package/dist/index.js +339 -117
- package/dist/serve.d.ts +1 -1
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Portable HTTP relay for the [DFOS protocol](https://protocol.dfos.com). Receives, verifies, stores, and serves identity chains, content chains, beacons, countersignatures, and content blobs.
|
|
4
4
|
|
|
5
|
-
See [RELAY.md](./RELAY.md) for the full
|
|
5
|
+
See [RELAY.md](./RELAY.md) for the full relay specification.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -12,6 +12,8 @@ npm install @metalabel/dfos-web-relay @metalabel/dfos-protocol
|
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
|
+
### Embedded (Hono app)
|
|
16
|
+
|
|
15
17
|
```typescript
|
|
16
18
|
import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
|
|
17
19
|
|
|
@@ -24,6 +26,77 @@ const relay = createRelay({
|
|
|
24
26
|
export default relay;
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
### Standalone (Node.js)
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { serve } from '@metalabel/dfos-web-relay/node';
|
|
33
|
+
|
|
34
|
+
serve({ port: 4444 });
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Routes
|
|
38
|
+
|
|
39
|
+
| Method | Path | Description |
|
|
40
|
+
| ------ | ---------------------------------------- | ---------------------------------------------------------------- |
|
|
41
|
+
| `GET` | `/.well-known/dfos-relay` | Relay metadata (DID, protocol version) |
|
|
42
|
+
| `POST` | `/operations` | Submit signed operations (identity, content, beacon, countersig) |
|
|
43
|
+
| `GET` | `/identities/:did` | Get identity chain state and operation log |
|
|
44
|
+
| `GET` | `/content/:contentId` | Get content chain state and operation log |
|
|
45
|
+
| `GET` | `/operations/:cid` | Get a single operation by CID |
|
|
46
|
+
| `GET` | `/beacons/:did` | Get beacon for an identity |
|
|
47
|
+
| `GET` | `/countersignatures/:cid` | Get countersignatures for an operation |
|
|
48
|
+
| `GET` | `/operations/:cid/countersignatures` | Same as above (alias) |
|
|
49
|
+
| `PUT` | `/content/:contentId/blob/:operationCID` | Upload blob (auth required) |
|
|
50
|
+
| `GET` | `/content/:contentId/blob` | Download blob at head (auth + credential) |
|
|
51
|
+
| `GET` | `/content/:contentId/blob/:ref` | Download blob at specific operation ref |
|
|
52
|
+
|
|
53
|
+
## Blob Authorization
|
|
54
|
+
|
|
55
|
+
**Upload**: Auth token required. Caller must be the chain creator or the signer of the referenced operation (enables delegated upload).
|
|
56
|
+
|
|
57
|
+
**Download**: Auth token required. Chain creator can download directly. Other identities must present a `DFOSContentRead` VC-JWT credential (issued by the creator) in the `X-Credential` header.
|
|
58
|
+
|
|
59
|
+
## Conformance Test Suite
|
|
60
|
+
|
|
61
|
+
The `conformance/` directory contains a Go integration test suite that exercises the full relay HTTP surface. It runs against any live relay via the `RELAY_URL` environment variable.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Run against a local relay
|
|
65
|
+
RELAY_URL=http://localhost:4444 go test -v -count=1 ./conformance/
|
|
66
|
+
|
|
67
|
+
# Run against a remote relay
|
|
68
|
+
RELAY_URL=https://registry.imajin.ai/relay go test -v -count=1 ./conformance/
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
71 tests covering:
|
|
72
|
+
|
|
73
|
+
- Well-known discovery
|
|
74
|
+
- Identity lifecycle (create, update, delete, batch, idempotency, controller key rotation)
|
|
75
|
+
- Content lifecycle (create, update, delete, fork rejection, post-delete rejection, notes, long chains)
|
|
76
|
+
- Content update after auth key rotation, multiple independent chains
|
|
77
|
+
- Operations by CID
|
|
78
|
+
- Beacons (create, replacement, not-found, unknown/deleted identity)
|
|
79
|
+
- Countersignatures (dedup, empty result, multi-witness, self-countersign, non-existent operation)
|
|
80
|
+
- Blob upload/download (CID verification, auth, credential-based access, multi-version, idempotent upload)
|
|
81
|
+
- Delegated content operations (write credentials, delegated blob upload, delegated delete)
|
|
82
|
+
- Credentials (expiry, scope mismatch, type enforcement, deleted issuer behavior)
|
|
83
|
+
- Signature verification (tampered signature, wrong signing key)
|
|
84
|
+
- Auth edge cases (wrong audience, expired token, rotated-out key)
|
|
85
|
+
- Batch processing (3-step dependency sort, content-identity sort, large batch, dedup, mixed valid/invalid, multi-chain)
|
|
86
|
+
- Input validation (malformed JSON, empty operations, invalid JWS)
|
|
87
|
+
|
|
88
|
+
The conformance suite depends on [`dfos-protocol-go`](../dfos-protocol-go) for protocol operations.
|
|
89
|
+
|
|
90
|
+
## Custom Store
|
|
91
|
+
|
|
92
|
+
Implement the `RelayStore` interface to use any persistence backend:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import type { RelayStore } from '@metalabel/dfos-web-relay';
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`MemoryRelayStore` is provided as a reference implementation and for testing.
|
|
99
|
+
|
|
27
100
|
## License
|
|
28
101
|
|
|
29
102
|
MIT
|
package/RELAY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DFOS Web Relay
|
|
2
2
|
|
|
3
|
-
An HTTP relay for the DFOS protocol — receives, verifies, stores, and serves identity chains, content chains, beacons, countersignatures, and content blobs.
|
|
3
|
+
An HTTP relay for the DFOS protocol — receives, verifies, stores, and serves identity chains, content chains, artifacts, beacons, countersignatures, and content blobs.
|
|
4
4
|
|
|
5
5
|
This spec is under active review. Discuss it in the [clear.txt](https://clear.dfos.com) space on DFOS.
|
|
6
6
|
|
|
@@ -24,7 +24,7 @@ The relay serves two distinct planes of data with different access models:
|
|
|
24
24
|
|
|
25
25
|
### Proof Plane (public)
|
|
26
26
|
|
|
27
|
-
Signed chain operations, beacons, and countersignatures. These are cryptographic proofs — anyone can verify them with a public key. The proof plane gossips freely: relays push operations to peers, peers verify and store independently.
|
|
27
|
+
Signed chain operations, artifacts, beacons, and countersignatures. These are cryptographic proofs — anyone can verify them with a public key. The proof plane gossips freely: relays push operations to peers, peers verify and store independently.
|
|
28
28
|
|
|
29
29
|
All proof plane routes are unauthenticated. The operations themselves carry their own authentication (Ed25519 signatures).
|
|
30
30
|
|
|
@@ -39,34 +39,36 @@ Content plane access requires two credentials:
|
|
|
39
39
|
|
|
40
40
|
The content creator (the DID that signed the genesis content operation) can always read their own blobs with just an auth token.
|
|
41
41
|
|
|
42
|
+
Content plane support is optional per relay. When disabled (`content: false` in the well-known response), all content plane routes return **501 Not Implemented** — not 404 (resource doesn't exist), but 501 (capability not supported).
|
|
43
|
+
|
|
42
44
|
---
|
|
43
45
|
|
|
44
46
|
## Operation Ingestion
|
|
45
47
|
|
|
46
|
-
All proof plane
|
|
48
|
+
All proof plane operations enter through a single endpoint: `POST /operations`. The request body is an array of JWS tokens — identity operations, content operations, artifacts, beacons, and countersignatures can be mixed freely in the same batch.
|
|
47
49
|
|
|
48
50
|
### Classification
|
|
49
51
|
|
|
50
52
|
Each token is classified by its JWS `typ` header:
|
|
51
53
|
|
|
52
|
-
| `typ` header |
|
|
53
|
-
| ---------------------- |
|
|
54
|
-
| `did:dfos:identity-op` |
|
|
55
|
-
| `did:dfos:content-op` |
|
|
56
|
-
| `did:dfos:
|
|
57
|
-
| `did:dfos:
|
|
58
|
-
| `did:dfos:
|
|
54
|
+
| `typ` header | Classification |
|
|
55
|
+
| ---------------------- | ------------------------ |
|
|
56
|
+
| `did:dfos:identity-op` | Identity chain operation |
|
|
57
|
+
| `did:dfos:content-op` | Content chain operation |
|
|
58
|
+
| `did:dfos:beacon` | Beacon announcement |
|
|
59
|
+
| `did:dfos:artifact` | Artifact |
|
|
60
|
+
| `did:dfos:countersign` | Countersignature |
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
Each operation type has its own `typ` header. Classification is unambiguous — no DID comparison needed.
|
|
61
63
|
|
|
62
64
|
### Dependency Sort
|
|
63
65
|
|
|
64
66
|
Within a batch, operations are sorted by dependency priority before processing:
|
|
65
67
|
|
|
66
68
|
1. **Identity operations** — must be processed first so their keys are available
|
|
67
|
-
2. **Beacons** — reference identity keys
|
|
68
|
-
3. **Content operations** — reference identity keys
|
|
69
|
-
4. **Countersignatures** — reference
|
|
69
|
+
2. **Beacons and artifacts** — reference identity keys for signature verification
|
|
70
|
+
3. **Content operations** — reference identity keys, may have chain dependencies
|
|
71
|
+
4. **Countersignatures** — reference identity keys and existing operations (target must exist)
|
|
70
72
|
|
|
71
73
|
Within each priority level, genesis operations (no `previousOperationCID`) are processed before extensions. This ensures that a single batch can bootstrap an entire identity-and-content lifecycle — including chained create + update operations — without multiple round trips.
|
|
72
74
|
|
|
@@ -74,19 +76,34 @@ Within each priority level, genesis operations (no `previousOperationCID`) are p
|
|
|
74
76
|
|
|
75
77
|
Each operation is verified against the relay's stored state:
|
|
76
78
|
|
|
77
|
-
- **Identity operations**:
|
|
78
|
-
- **Content operations**:
|
|
79
|
+
- **Identity operations**: Extension operations are verified against the relay's current trusted state using O(1) extension verification — the trusted head state plus the new operation is sufficient. Genesis operations verify the single-operation chain. The relay uses `verifyIdentityChain()` / `verifyIdentityExtensionFromTrustedState()` from the protocol library
|
|
80
|
+
- **Content operations**: Extension operations are verified against trusted state with `enforceAuthorization: true`. Non-creator signers must include a `DFOSContentWrite` VC-JWT. The relay uses `verifyContentChain()` / `verifyContentExtensionFromTrustedState()` from the protocol library
|
|
81
|
+
- **Artifacts**: Signature is verified against the signing DID's current identity state. CID integrity is checked. Payload must conform to the declared `$schema`. CBOR-encoded payload must not exceed 16384 bytes
|
|
79
82
|
- **Beacons**: Signature, CID integrity, and clock skew are verified. Replace-on-newer: only the most recent beacon per DID is retained
|
|
80
|
-
- **Countersignatures**:
|
|
81
|
-
- **Beacon countersignatures**: The referenced beacon must exist and the countersignature CID must match the current beacon CID
|
|
83
|
+
- **Countersignatures**: Two-phase verification. Protocol-level (stateless): signature, CID integrity, payload schema. Relay-level (stateful): target CID must exist in the relay, witness DID must differ from the target's author DID, one countersign per witness per target
|
|
82
84
|
|
|
83
85
|
### Fork Policy
|
|
84
86
|
|
|
85
87
|
First-seen-wins. If a chain head has already advanced past the `previousOperationCID` referenced by an incoming operation, the new operation is rejected. The relay does not attempt to resolve forks — the first valid extension wins.
|
|
86
88
|
|
|
87
|
-
Duplicate submissions (same operation CID) are silently accepted (idempotent).
|
|
89
|
+
Duplicate submissions (same operation CID, same JWS token) are silently accepted (idempotent). Submissions with the same CID but a different JWS token are rejected — since Ed25519 is deterministic, a different token for the same payload means a different signing key, which is either a self-countersign attempt or an unauthorized re-sign.
|
|
90
|
+
|
|
91
|
+
Duplicate countersignatures (same witness DID, same target CID) MUST be deduplicated — one countersign per witness per target. The relay MUST NOT store multiple attestations from the same witness for the same target. Resubmission SHOULD return `accepted` (idempotent).
|
|
92
|
+
|
|
93
|
+
### Deletion Semantics
|
|
94
|
+
|
|
95
|
+
Deletion means the identity stops being an active participant. Historical operations remain verifiable — keys persist in state for signature verification — but no new acts flow from a deleted identity.
|
|
96
|
+
|
|
97
|
+
Specifically:
|
|
88
98
|
|
|
89
|
-
|
|
99
|
+
- **Identity operations after deletion**: Rejected. A deleted identity chain is sealed.
|
|
100
|
+
- **Content operations after deletion**: Rejected. A deleted content chain is sealed.
|
|
101
|
+
- **Beacons from deleted identities**: Rejected. A deleted identity MUST NOT publish new beacons.
|
|
102
|
+
- **Artifacts from deleted identities**: Rejected. A deleted identity MUST NOT publish new artifacts.
|
|
103
|
+
- **Credentials from deleted issuers**: Rejected. Identity deletion revokes all authority, including outstanding `DFOSContentRead` and `DFOSContentWrite` credentials issued by the deleted identity. Credentials that were valid at time of issuance cease to be honored once the issuer is deleted.
|
|
104
|
+
- **Countersignatures from deleted witnesses**: Rejected. A deleted identity MUST NOT publish new countersignatures. Countersignatures on operations by deleted authors are still accepted — deletion of the target's author does not prevent other identities from attesting.
|
|
105
|
+
|
|
106
|
+
Self-countersignatures — where the witness DID matches the target's author DID — are rejected at the relay level. A countersignature's semantic is "a distinct witness attests." The protocol-level verifier is stateless and does not enforce this; the relay resolves the target's author and rejects self-attestation.
|
|
90
107
|
|
|
91
108
|
### Result Ordering
|
|
92
109
|
|
|
@@ -94,14 +111,226 @@ Ingestion results are returned in the same order as the input `operations` array
|
|
|
94
111
|
|
|
95
112
|
---
|
|
96
113
|
|
|
114
|
+
## Artifacts
|
|
115
|
+
|
|
116
|
+
Artifacts are standalone signed inline documents — immutable, CID-addressable proof plane primitives. Unlike chain operations which extend a sequence, an artifact is a single signed statement with no predecessor or successor.
|
|
117
|
+
|
|
118
|
+
### Payload
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"version": 1,
|
|
123
|
+
"type": "artifact",
|
|
124
|
+
"did": "did:dfos:...",
|
|
125
|
+
"content": {
|
|
126
|
+
"$schema": "https://schemas.dfos.com/profile/v1",
|
|
127
|
+
"name": "My Relay",
|
|
128
|
+
"description": "A relay for the dark forest"
|
|
129
|
+
},
|
|
130
|
+
"createdAt": "2026-03-25T00:00:00.000Z"
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The `content` object MUST include a `$schema` string that identifies the artifact's schema. The schema acts as a discriminator — consumers use it to determine how to interpret the artifact's content. Schema names are free-form strings (no protocol-level registry). Communities may establish conventions for well-known schemas.
|
|
135
|
+
|
|
136
|
+
### Constraints
|
|
137
|
+
|
|
138
|
+
- **JWS `typ` header**: `did:dfos:artifact`
|
|
139
|
+
- **Max payload size**: 16384 bytes CBOR-encoded. This is a protocol constant — not configurable per relay
|
|
140
|
+
- **Immutability**: Once ingested, an artifact is never updated or replaced. To "update" an artifact's content, publish a new artifact
|
|
141
|
+
- **CID-addressable**: Each artifact is addressed by the CID of its CBOR-encoded payload
|
|
142
|
+
|
|
143
|
+
### Verification
|
|
144
|
+
|
|
145
|
+
1. JWS signature verification against the signing DID's current key state
|
|
146
|
+
2. CID integrity — the payload CID matches the computed CID from the raw payload bytes
|
|
147
|
+
3. Payload schema validation — the payload conforms to the artifact structure (`version`, `type`, `did`, `content` with `$schema`, `createdAt`)
|
|
148
|
+
4. Size limit — CBOR-encoded payload does not exceed 16384 bytes
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Countersignatures
|
|
153
|
+
|
|
154
|
+
A countersignature is a standalone witness attestation — a signed statement that references a target operation by CID. Unlike the original operation primitives (which carry the data itself), a countersign is pure attestation: "I, witness W, attest to operation X."
|
|
155
|
+
|
|
156
|
+
### Payload
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"version": 1,
|
|
161
|
+
"type": "countersign",
|
|
162
|
+
"did": "did:dfos:witness...",
|
|
163
|
+
"targetCID": "bafy...",
|
|
164
|
+
"createdAt": "2026-03-25T00:00:00.000Z"
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Properties
|
|
169
|
+
|
|
170
|
+
- **JWS `typ` header**: `did:dfos:countersign`
|
|
171
|
+
- **Own CID**: Each countersign has its own CID, distinct from the target. This avoids the ambiguity of multiple JWS tokens sharing the same CID
|
|
172
|
+
- **Stateless verification**: Signature + CID integrity + payload schema. No relay state required to verify the cryptographic validity of a countersign
|
|
173
|
+
- **Composable**: The `targetCID` can reference any CID-addressable operation — content ops, beacons, artifacts, identity ops, even other countersigns
|
|
174
|
+
- **Immutable**: Once published, a countersign is permanent
|
|
175
|
+
|
|
176
|
+
### Relay-Level Checks
|
|
177
|
+
|
|
178
|
+
The relay enforces semantic rules beyond cryptographic validity:
|
|
179
|
+
|
|
180
|
+
1. **Target exists**: The `targetCID` must reference an operation already stored in the relay
|
|
181
|
+
2. **Witness ≠ author**: The countersign's `did` (witness) must differ from the target operation's author DID
|
|
182
|
+
3. **Deduplication**: One countersign per witness per target. If the same witness submits a second countersign for the same target, the relay accepts idempotently
|
|
183
|
+
4. **Deleted witness rejection**: Countersigns from deleted identities are rejected
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
97
187
|
## Relay Identity
|
|
98
188
|
|
|
99
|
-
Every relay has a DID
|
|
189
|
+
Every relay has a DID that resolves on its own proof plane. The relay DID serves as:
|
|
100
190
|
|
|
101
191
|
- **Auth token audience**: Auth tokens are scoped to a specific relay via the JWT `aud` claim, preventing cross-relay token replay
|
|
102
192
|
- **Peer identity**: When relays gossip proof plane data to each other, the relay DID identifies the peer
|
|
193
|
+
- **Self-proof anchor**: The relay's identity chain lives in its own store, verifiable by anyone querying the relay
|
|
194
|
+
|
|
195
|
+
### Relay Profile
|
|
196
|
+
|
|
197
|
+
The relay MUST publish a profile artifact signed by its own DID using the HEAD key state. The profile artifact uses the `https://schemas.dfos.com/profile/v1` schema:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{
|
|
201
|
+
"$schema": "https://schemas.dfos.com/profile/v1",
|
|
202
|
+
"name": "edge.relay.dfos.com",
|
|
203
|
+
"description": "Cloudflare edge relay for the DFOS network",
|
|
204
|
+
"image": { "id": "relay-avatar", "uri": "https://cdn.example.com/avatar.png" },
|
|
205
|
+
"operator": "Metalabel",
|
|
206
|
+
"motd": "Welcome to the dark forest"
|
|
207
|
+
}
|
|
208
|
+
```
|
|
103
209
|
|
|
104
|
-
The
|
|
210
|
+
All fields are optional except `name`, which SHOULD be present. The `image.uri` field is any valid URI (operator choice — CDN, content-plane reference, or any resolvable URL). The profile JWS token is inlined in the well-known response — self-proving, no extra fetch needed.
|
|
211
|
+
|
|
212
|
+
### Well-Known Endpoint (`GET /.well-known/dfos-relay`)
|
|
213
|
+
|
|
214
|
+
Returns relay metadata. All fields are required — `profile` is the relay's proof of DID controllership (an artifact JWS signed by the relay DID's controller key).
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"did": "did:dfos:edgerelay0000000000000",
|
|
219
|
+
"protocol": "dfos-web-relay",
|
|
220
|
+
"version": "0.1.0",
|
|
221
|
+
"proof": true,
|
|
222
|
+
"content": false,
|
|
223
|
+
"profile": "eyJhbGciOiJFZERTQSIs..."
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Field | Type | Description |
|
|
228
|
+
| ---------- | ------- | -------------------------------------------------------------------------- |
|
|
229
|
+
| `did` | string | The relay's DID, resolvable on this relay's proof plane |
|
|
230
|
+
| `protocol` | string | Protocol identifier, always `"dfos-web-relay"` |
|
|
231
|
+
| `version` | string | Relay protocol version (semver) |
|
|
232
|
+
| `proof` | boolean | MUST be `true`. A relay without proof plane capability is not a relay |
|
|
233
|
+
| `content` | boolean | Whether the relay supports content plane (blob upload/download) |
|
|
234
|
+
| `profile` | string | The relay's profile artifact as a compact JWS token — self-proving payload |
|
|
235
|
+
|
|
236
|
+
`proof: false` is not a valid value. A compliant relay always serves the proof plane.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Operation Log
|
|
241
|
+
|
|
242
|
+
The relay maintains a global append-only operation log. Every successfully ingested operation (identity ops, content ops, artifacts, beacons, countersignatures) is appended to the log in ingestion order.
|
|
243
|
+
|
|
244
|
+
### Global Log (`GET /log?after={cid}&limit=N`)
|
|
245
|
+
|
|
246
|
+
Returns log entries starting after the given CID cursor.
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"entries": [
|
|
251
|
+
{
|
|
252
|
+
"cid": "bafy...",
|
|
253
|
+
"jwsToken": "eyJhbGciOiJFZERTQSIs...",
|
|
254
|
+
"kind": "identity-op",
|
|
255
|
+
"chainId": "did:dfos:..."
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"cid": "bafy...",
|
|
259
|
+
"jwsToken": "eyJhbGciOiJFZERTQSIs...",
|
|
260
|
+
"kind": "artifact",
|
|
261
|
+
"chainId": "did:dfos:..."
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
"cursor": "bafy..."
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
| Field | Type | Description |
|
|
269
|
+
| -------------------- | ------------ | -------------------------------------------------------------------------------- |
|
|
270
|
+
| `entries[].cid` | string | Operation CID |
|
|
271
|
+
| `entries[].jwsToken` | string | The full compact JWS token — makes the log self-contained for sync |
|
|
272
|
+
| `entries[].kind` | string | Operation kind: `identity-op`, `content-op`, `beacon`, `artifact`, `countersign` |
|
|
273
|
+
| `entries[].chainId` | string | DID (identity/beacon/artifact), contentId (content), or targetCID (countersign) |
|
|
274
|
+
| `cursor` | string\|null | CID to pass as `after` for the next page. `null` means caught up |
|
|
275
|
+
|
|
276
|
+
Parameters:
|
|
277
|
+
|
|
278
|
+
- **`after`** (optional): CID cursor. Omit to start from the beginning of the log
|
|
279
|
+
- **`limit`** (optional): Max entries to return. Default: 100. Max: 1000
|
|
280
|
+
|
|
281
|
+
Pagination is forward-only. The log is ordered by ingestion time. JWS tokens are included in every entry because proof-plane JWS payloads are bounded (chain operations and artifacts have finite size), keeping the log self-contained — a syncing peer can replay the log without separate fetches.
|
|
282
|
+
|
|
283
|
+
### Per-Chain Logs
|
|
284
|
+
|
|
285
|
+
Identity and content chains expose their own log views with the same cursor-based pagination:
|
|
286
|
+
|
|
287
|
+
- `GET /identities/:did/log?after={cid}&limit=N`
|
|
288
|
+
- `GET /content/:contentId/log?after={cid}&limit=N`
|
|
289
|
+
|
|
290
|
+
Same cursor-based pagination parameters as the global log. Per-chain log entries include `{ cid, jwsToken }` — the chain-specific subset of the global log entry shape. Returns operations belonging to that chain in chain order.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Identity and Content State
|
|
295
|
+
|
|
296
|
+
State endpoints return projected state — the computed result of replaying the chain — without embedding the full operation log.
|
|
297
|
+
|
|
298
|
+
### Identity State (`GET /identities/:did`)
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
{
|
|
302
|
+
"did": "did:dfos:abc123...",
|
|
303
|
+
"headCID": "bafy...",
|
|
304
|
+
"state": {
|
|
305
|
+
"did": "did:dfos:abc123...",
|
|
306
|
+
"isDeleted": false,
|
|
307
|
+
"authKeys": [...],
|
|
308
|
+
"assertKeys": [...],
|
|
309
|
+
"controllerKeys": [...]
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Content State (`GET /content/:contentId`)
|
|
315
|
+
|
|
316
|
+
```json
|
|
317
|
+
{
|
|
318
|
+
"contentId": "abc123...",
|
|
319
|
+
"genesisCID": "bafy...",
|
|
320
|
+
"headCID": "bafy...",
|
|
321
|
+
"state": {
|
|
322
|
+
"contentId": "abc123...",
|
|
323
|
+
"genesisCID": "bafy...",
|
|
324
|
+
"headCID": "bafy...",
|
|
325
|
+
"isDeleted": false,
|
|
326
|
+
"currentDocumentCID": "bafy...",
|
|
327
|
+
"length": 1,
|
|
328
|
+
"creatorDID": "did:dfos:..."
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Chain history is available via the per-chain log routes described above.
|
|
105
334
|
|
|
106
335
|
---
|
|
107
336
|
|
|
@@ -167,10 +396,15 @@ interface RelayStore {
|
|
|
167
396
|
|
|
168
397
|
getCountersignatures(operationCID: string): Promise<string[]>;
|
|
169
398
|
addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
|
|
399
|
+
|
|
400
|
+
appendToLog(entry: LogEntry): Promise<void>;
|
|
401
|
+
readLog(params: { after?: string; limit: number }): Promise<LogPage>;
|
|
170
402
|
}
|
|
171
403
|
```
|
|
172
404
|
|
|
173
|
-
The
|
|
405
|
+
The `appendToLog` / `readLog` pair supports both the global log and per-chain log queries. The store implementation determines how to scope queries (e.g., by filtering on `chainId`).
|
|
406
|
+
|
|
407
|
+
The package includes `MemoryRelayStore` for development and testing. Production deployments would implement the interface over Postgres, SQLite, D1, or any durable store.
|
|
174
408
|
|
|
175
409
|
---
|
|
176
410
|
|
|
@@ -179,9 +413,15 @@ The package includes `MemoryRelayStore` for development and testing. Production
|
|
|
179
413
|
```typescript
|
|
180
414
|
import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
|
|
181
415
|
|
|
182
|
-
|
|
183
|
-
|
|
416
|
+
// JIT mode — generates relay identity + profile artifact at startup
|
|
417
|
+
const relay = await createRelay({
|
|
418
|
+
store: new MemoryRelayStore(),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Or provide a pre-created identity (production)
|
|
422
|
+
const relay = await createRelay({
|
|
184
423
|
store: new MemoryRelayStore(),
|
|
424
|
+
identity: { did: 'did:dfos:myrelay...', profileArtifactJws: '...' },
|
|
185
425
|
});
|
|
186
426
|
|
|
187
427
|
// Mount on any Hono-compatible runtime
|
|
@@ -198,8 +438,11 @@ The returned Hono app exposes:
|
|
|
198
438
|
| `GET` | `/operations/:cid/countersignatures` | proof | none |
|
|
199
439
|
| `GET` | `/countersignatures/:cid` | proof | none |
|
|
200
440
|
| `GET` | `/identities/:did` | proof | none |
|
|
441
|
+
| `GET` | `/identities/:did/log` | proof | none |
|
|
201
442
|
| `GET` | `/content/:contentId` | proof | none |
|
|
443
|
+
| `GET` | `/content/:contentId/log` | proof | none |
|
|
202
444
|
| `GET` | `/beacons/:did` | proof | none |
|
|
445
|
+
| `GET` | `/log` | proof | none |
|
|
203
446
|
| `PUT` | `/content/:contentId/blob/:opCID` | content | auth token |
|
|
204
447
|
| `GET` | `/content/:contentId/blob[/:ref]` | content | auth token + credential |
|
|
205
448
|
|
|
@@ -210,5 +453,5 @@ The returned Hono app exposes:
|
|
|
210
453
|
- **Peer gossip**: Proactive push of proof plane operations to other relays
|
|
211
454
|
- **Rate limiting / anti-spam**: Operational concern, not protocol concern
|
|
212
455
|
- **Docker/CF reference deployments**: Focus on the core library first
|
|
213
|
-
- **Pagination**: Chain logs are returned in full — fine for v1, needs pagination at scale
|
|
214
456
|
- **Blob size limits**: No enforcement yet — production deployments should add limits at the middleware layer
|
|
457
|
+
- **Artifact `$schema` registry**: Schema names are free-form strings for now — no formal registry or validation beyond structural checks
|
package/dist/index.d.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import { Hono } from 'hono';
|
|
2
1
|
import { VerifiedIdentity, VerifiedContentChain, VerifiedBeacon } from '@metalabel/dfos-protocol/chain';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
3
|
|
|
4
|
+
interface RelayIdentity {
|
|
5
|
+
/** The relay's DID */
|
|
6
|
+
did: string;
|
|
7
|
+
/** Profile artifact JWS token (signed by the relay DID) */
|
|
8
|
+
profileArtifactJws: string;
|
|
9
|
+
}
|
|
4
10
|
interface RelayOptions {
|
|
5
|
-
/** The relay's DID — used as auth token audience and published in well-known */
|
|
6
|
-
relayDID: string;
|
|
7
11
|
/** Storage backend */
|
|
8
12
|
store: RelayStore;
|
|
13
|
+
/** Pre-created relay identity — if omitted, a JIT identity and profile are generated */
|
|
14
|
+
identity?: RelayIdentity;
|
|
15
|
+
/** Whether content plane routes are enabled (default: true) */
|
|
16
|
+
content?: boolean;
|
|
9
17
|
}
|
|
10
18
|
interface StoredIdentityChain {
|
|
11
19
|
did: string;
|
|
12
20
|
/** Ordered JWS tokens from genesis to head */
|
|
13
21
|
log: string[];
|
|
22
|
+
/** CID of the most recent operation */
|
|
23
|
+
headCID: string;
|
|
24
|
+
/** createdAt timestamp of the most recent operation */
|
|
25
|
+
lastCreatedAt: string;
|
|
14
26
|
state: VerifiedIdentity;
|
|
15
27
|
}
|
|
16
28
|
interface StoredContentChain {
|
|
@@ -18,6 +30,8 @@ interface StoredContentChain {
|
|
|
18
30
|
genesisCID: string;
|
|
19
31
|
/** Ordered JWS tokens from genesis to head */
|
|
20
32
|
log: string[];
|
|
33
|
+
/** createdAt timestamp of the most recent operation */
|
|
34
|
+
lastCreatedAt: string;
|
|
21
35
|
state: VerifiedContentChain;
|
|
22
36
|
}
|
|
23
37
|
interface StoredBeacon {
|
|
@@ -30,8 +44,8 @@ interface StoredOperation {
|
|
|
30
44
|
cid: string;
|
|
31
45
|
jwsToken: string;
|
|
32
46
|
/** Which chain type this operation belongs to */
|
|
33
|
-
chainType: 'identity' | 'content';
|
|
34
|
-
/** The chain identifier — DID for identity, contentId for content */
|
|
47
|
+
chainType: 'identity' | 'content' | 'artifact' | 'beacon' | 'countersign';
|
|
48
|
+
/** The chain identifier — DID for identity/beacon/artifact, contentId for content, targetCID for countersign */
|
|
35
49
|
chainId: string;
|
|
36
50
|
}
|
|
37
51
|
/** Key for blob storage — deduplicates across chains sharing the same document */
|
|
@@ -39,6 +53,15 @@ interface BlobKey {
|
|
|
39
53
|
creatorDID: string;
|
|
40
54
|
documentCID: string;
|
|
41
55
|
}
|
|
56
|
+
/** A single entry in the global append-only operation log */
|
|
57
|
+
interface LogEntry {
|
|
58
|
+
cid: string;
|
|
59
|
+
jwsToken: string;
|
|
60
|
+
kind: OperationKind;
|
|
61
|
+
chainId: string;
|
|
62
|
+
}
|
|
63
|
+
/** All operation kinds in the protocol */
|
|
64
|
+
type OperationKind = 'identity-op' | 'content-op' | 'beacon' | 'artifact' | 'countersign';
|
|
42
65
|
/**
|
|
43
66
|
* Storage backend for a DFOS web relay
|
|
44
67
|
*
|
|
@@ -63,24 +86,45 @@ interface RelayStore {
|
|
|
63
86
|
putBlob(key: BlobKey, data: Uint8Array): Promise<void>;
|
|
64
87
|
getCountersignatures(operationCID: string): Promise<string[]>;
|
|
65
88
|
addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
|
|
89
|
+
appendToLog(entry: LogEntry): Promise<void>;
|
|
90
|
+
readLog(params: {
|
|
91
|
+
after?: string;
|
|
92
|
+
limit: number;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
entries: LogEntry[];
|
|
95
|
+
cursor: string | null;
|
|
96
|
+
}>;
|
|
66
97
|
}
|
|
67
98
|
interface IngestionResult {
|
|
68
99
|
cid: string;
|
|
69
100
|
status: 'accepted' | 'rejected';
|
|
70
101
|
error?: string;
|
|
71
102
|
/** What was ingested */
|
|
72
|
-
kind?:
|
|
103
|
+
kind?: OperationKind;
|
|
73
104
|
/** Chain identifier if applicable */
|
|
74
105
|
chainId?: string;
|
|
75
106
|
}
|
|
76
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Generate a relay identity and profile artifact, ingest both into the store
|
|
110
|
+
*
|
|
111
|
+
* Creates an Ed25519 keypair, signs an identity genesis operation, derives
|
|
112
|
+
* the DID, then signs a profile artifact with the relay's name. Both the
|
|
113
|
+
* identity genesis and profile artifact are ingested into the store so
|
|
114
|
+
* they are available via the relay's proof plane routes.
|
|
115
|
+
*/
|
|
116
|
+
declare const bootstrapRelayIdentity: (store: RelayStore) => Promise<RelayIdentity>;
|
|
117
|
+
|
|
77
118
|
/**
|
|
78
119
|
* Create a DFOS web relay Hono application
|
|
79
120
|
*
|
|
80
121
|
* The returned app is portable — mount it on any Hono-compatible runtime
|
|
81
122
|
* (Node.js, Cloudflare Workers, Deno, Bun, etc.).
|
|
123
|
+
*
|
|
124
|
+
* When `identity` is provided, the relay uses the given DID and profile. When
|
|
125
|
+
* omitted, a JIT identity and profile artifact are generated at startup.
|
|
82
126
|
*/
|
|
83
|
-
declare const createRelay: (options: RelayOptions) => Hono
|
|
127
|
+
declare const createRelay: (options: RelayOptions) => Promise<Hono>;
|
|
84
128
|
|
|
85
129
|
/**
|
|
86
130
|
* In-memory relay store — all data lives in Maps, lost on restart
|
|
@@ -94,6 +138,7 @@ declare class MemoryRelayStore implements RelayStore {
|
|
|
94
138
|
private beacons;
|
|
95
139
|
private blobs;
|
|
96
140
|
private countersignatures;
|
|
141
|
+
private operationLog;
|
|
97
142
|
getOperation(cid: string): Promise<StoredOperation | undefined>;
|
|
98
143
|
putOperation(op: StoredOperation): Promise<void>;
|
|
99
144
|
getIdentityChain(did: string): Promise<StoredIdentityChain | undefined>;
|
|
@@ -106,6 +151,14 @@ declare class MemoryRelayStore implements RelayStore {
|
|
|
106
151
|
putBlob(key: BlobKey, data: Uint8Array): Promise<void>;
|
|
107
152
|
getCountersignatures(operationCID: string): Promise<string[]>;
|
|
108
153
|
addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
|
|
154
|
+
appendToLog(entry: LogEntry): Promise<void>;
|
|
155
|
+
readLog(params: {
|
|
156
|
+
after?: string;
|
|
157
|
+
limit: number;
|
|
158
|
+
}): Promise<{
|
|
159
|
+
entries: LogEntry[];
|
|
160
|
+
cursor: string | null;
|
|
161
|
+
}>;
|
|
109
162
|
}
|
|
110
163
|
|
|
111
164
|
/**
|
|
@@ -140,4 +193,4 @@ declare const createCurrentKeyResolver: (store: RelayStore) => (kid: string) =>
|
|
|
140
193
|
*/
|
|
141
194
|
declare const ingestOperations: (tokens: string[], store: RelayStore) => Promise<IngestionResult[]>;
|
|
142
195
|
|
|
143
|
-
export { type BlobKey, type IngestionResult, MemoryRelayStore, type RelayOptions, type RelayStore, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, createCurrentKeyResolver, createKeyResolver, createRelay, ingestOperations };
|
|
196
|
+
export { type BlobKey, type IngestionResult, type LogEntry, MemoryRelayStore, type OperationKind, type RelayIdentity, type RelayOptions, type RelayStore, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, bootstrapRelayIdentity, createCurrentKeyResolver, createKeyResolver, createRelay, ingestOperations };
|
package/dist/index.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
// src/bootstrap.ts
|
|
2
|
+
import {
|
|
3
|
+
encodeEd25519Multikey,
|
|
4
|
+
signArtifact,
|
|
5
|
+
signIdentityOperation
|
|
6
|
+
} from "@metalabel/dfos-protocol/chain";
|
|
7
|
+
import {
|
|
8
|
+
createNewEd25519Keypair,
|
|
9
|
+
generateId,
|
|
10
|
+
signPayloadEd25519
|
|
11
|
+
} from "@metalabel/dfos-protocol/crypto";
|
|
10
12
|
|
|
11
13
|
// src/ingest.ts
|
|
12
14
|
import {
|
|
13
15
|
decodeMultikey,
|
|
16
|
+
verifyArtifact,
|
|
14
17
|
verifyBeacon,
|
|
15
|
-
verifyBeaconCountersignature,
|
|
16
18
|
verifyContentChain,
|
|
19
|
+
verifyContentExtensionFromTrustedState,
|
|
17
20
|
verifyCountersignature,
|
|
18
|
-
verifyIdentityChain
|
|
21
|
+
verifyIdentityChain,
|
|
22
|
+
verifyIdentityExtensionFromTrustedState
|
|
19
23
|
} from "@metalabel/dfos-protocol/chain";
|
|
20
24
|
import { dagCborCanonicalEncode, decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
|
|
21
25
|
var classify = (jwsToken) => {
|
|
@@ -44,30 +48,10 @@ var classify = (jwsToken) => {
|
|
|
44
48
|
}
|
|
45
49
|
if (typ === "did:dfos:content-op") {
|
|
46
50
|
const opDID = typeof payload["did"] === "string" ? payload["did"] : null;
|
|
47
|
-
if (opDID && kidDID && kidDID !== opDID) {
|
|
48
|
-
return {
|
|
49
|
-
...base,
|
|
50
|
-
kind: "countersig",
|
|
51
|
-
referencedDID: opDID,
|
|
52
|
-
signerDID: kidDID,
|
|
53
|
-
priority: 3,
|
|
54
|
-
previousCID: null
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
51
|
return { ...base, kind: "content-op", referencedDID: null, signerDID: opDID, priority: 2 };
|
|
58
52
|
}
|
|
59
53
|
if (typ === "did:dfos:beacon") {
|
|
60
54
|
const beaconDID = typeof payload["did"] === "string" ? payload["did"] : null;
|
|
61
|
-
if (beaconDID && kidDID && kidDID !== beaconDID) {
|
|
62
|
-
return {
|
|
63
|
-
...base,
|
|
64
|
-
kind: "beacon-countersig",
|
|
65
|
-
referencedDID: beaconDID,
|
|
66
|
-
signerDID: kidDID,
|
|
67
|
-
priority: 3,
|
|
68
|
-
previousCID: null
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
55
|
return {
|
|
72
56
|
...base,
|
|
73
57
|
kind: "beacon",
|
|
@@ -77,6 +61,30 @@ var classify = (jwsToken) => {
|
|
|
77
61
|
previousCID: null
|
|
78
62
|
};
|
|
79
63
|
}
|
|
64
|
+
if (typ === "did:dfos:countersign") {
|
|
65
|
+
const witnessDID = typeof payload["did"] === "string" ? payload["did"] : null;
|
|
66
|
+
return {
|
|
67
|
+
...base,
|
|
68
|
+
kind: "countersign",
|
|
69
|
+
referencedDID: witnessDID,
|
|
70
|
+
signerDID: null,
|
|
71
|
+
priority: 3,
|
|
72
|
+
// processed last — target must already be ingested
|
|
73
|
+
previousCID: null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (typ === "did:dfos:artifact") {
|
|
77
|
+
const artifactDID = typeof payload["did"] === "string" ? payload["did"] : null;
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
kind: "artifact",
|
|
81
|
+
referencedDID: artifactDID,
|
|
82
|
+
signerDID: null,
|
|
83
|
+
priority: 1,
|
|
84
|
+
// same as beacons — needs identity keys resolved first
|
|
85
|
+
previousCID: null
|
|
86
|
+
};
|
|
87
|
+
}
|
|
80
88
|
return unknown;
|
|
81
89
|
};
|
|
82
90
|
var createKeyResolver = (store) => async (kid) => {
|
|
@@ -135,15 +143,32 @@ var ingestIdentityOp = async (jwsToken, store) => {
|
|
|
135
143
|
const encoded = await dagCborCanonicalEncode(payload);
|
|
136
144
|
const cid = encoded.cid.toString();
|
|
137
145
|
const existing = await store.getOperation(cid);
|
|
138
|
-
if (existing)
|
|
146
|
+
if (existing) {
|
|
147
|
+
if (existing.jwsToken !== jwsToken) {
|
|
148
|
+
return {
|
|
149
|
+
cid,
|
|
150
|
+
status: "rejected",
|
|
151
|
+
error: "operation already exists with a different signature"
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { cid, status: "accepted", kind: "identity-op", chainId: existing.chainId };
|
|
155
|
+
}
|
|
139
156
|
const opType = payload["type"];
|
|
140
157
|
const isGenesis = opType === "create";
|
|
141
158
|
if (isGenesis) {
|
|
142
|
-
const
|
|
143
|
-
const
|
|
159
|
+
const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
|
|
160
|
+
const createdAt = payload["createdAt"];
|
|
161
|
+
const chain2 = {
|
|
162
|
+
did: identity.did,
|
|
163
|
+
log: [jwsToken],
|
|
164
|
+
headCID: cid,
|
|
165
|
+
lastCreatedAt: createdAt,
|
|
166
|
+
state: identity
|
|
167
|
+
};
|
|
144
168
|
await store.putIdentityChain(chain2);
|
|
145
|
-
await store.putOperation({ cid, jwsToken, chainType: "identity", chainId:
|
|
146
|
-
|
|
169
|
+
await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: identity.did });
|
|
170
|
+
await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: identity.did });
|
|
171
|
+
return { cid, status: "accepted", kind: "identity-op", chainId: identity.did };
|
|
147
172
|
}
|
|
148
173
|
const kid = decoded.header.kid;
|
|
149
174
|
const hashIdx = kid.indexOf("#");
|
|
@@ -151,11 +176,22 @@ var ingestIdentityOp = async (jwsToken, store) => {
|
|
|
151
176
|
const did = kid.substring(0, hashIdx);
|
|
152
177
|
const chain = await store.getIdentityChain(did);
|
|
153
178
|
if (!chain) return { cid, status: "rejected", error: `unknown identity: ${did}` };
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
179
|
+
const extResult = await verifyIdentityExtensionFromTrustedState({
|
|
180
|
+
currentState: chain.state,
|
|
181
|
+
headCID: chain.headCID,
|
|
182
|
+
lastCreatedAt: chain.lastCreatedAt,
|
|
183
|
+
newOp: jwsToken
|
|
184
|
+
});
|
|
185
|
+
const updated = {
|
|
186
|
+
did: chain.did,
|
|
187
|
+
log: [...chain.log, jwsToken],
|
|
188
|
+
headCID: extResult.operationCID,
|
|
189
|
+
lastCreatedAt: extResult.createdAt,
|
|
190
|
+
state: extResult.state
|
|
191
|
+
};
|
|
157
192
|
await store.putIdentityChain(updated);
|
|
158
193
|
await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: did });
|
|
194
|
+
await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: did });
|
|
159
195
|
return { cid, status: "accepted", kind: "identity-op", chainId: did };
|
|
160
196
|
};
|
|
161
197
|
var ingestContentOp = async (jwsToken, store) => {
|
|
@@ -165,25 +201,44 @@ var ingestContentOp = async (jwsToken, store) => {
|
|
|
165
201
|
const encoded = await dagCborCanonicalEncode(payload);
|
|
166
202
|
const cid = encoded.cid.toString();
|
|
167
203
|
const existing = await store.getOperation(cid);
|
|
168
|
-
if (existing)
|
|
204
|
+
if (existing) {
|
|
205
|
+
if (existing.jwsToken !== jwsToken) {
|
|
206
|
+
return {
|
|
207
|
+
cid,
|
|
208
|
+
status: "rejected",
|
|
209
|
+
error: "operation already exists with a different signature"
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return { cid, status: "accepted", kind: "content-op", chainId: existing.chainId };
|
|
213
|
+
}
|
|
214
|
+
const signerDID = payload["did"];
|
|
215
|
+
if (typeof signerDID === "string") {
|
|
216
|
+
const signerIdentity = await store.getIdentityChain(signerDID);
|
|
217
|
+
if (signerIdentity?.state.isDeleted) {
|
|
218
|
+
return { cid, status: "rejected", error: "signer identity is deleted" };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
169
221
|
const resolveKey = createKeyResolver(store);
|
|
170
222
|
const opType = payload["type"];
|
|
171
223
|
const isGenesis = opType === "create";
|
|
172
224
|
if (isGenesis) {
|
|
173
|
-
const
|
|
225
|
+
const content = await verifyContentChain({
|
|
174
226
|
log: [jwsToken],
|
|
175
227
|
resolveKey,
|
|
176
228
|
enforceAuthorization: true
|
|
177
229
|
});
|
|
230
|
+
const createdAt = payload["createdAt"];
|
|
178
231
|
const chain2 = {
|
|
179
|
-
contentId:
|
|
180
|
-
genesisCID:
|
|
232
|
+
contentId: content.contentId,
|
|
233
|
+
genesisCID: content.genesisCID,
|
|
181
234
|
log: [jwsToken],
|
|
182
|
-
|
|
235
|
+
lastCreatedAt: createdAt,
|
|
236
|
+
state: content
|
|
183
237
|
};
|
|
184
238
|
await store.putContentChain(chain2);
|
|
185
|
-
await store.putOperation({ cid, jwsToken, chainType: "content", chainId:
|
|
186
|
-
|
|
239
|
+
await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content.contentId });
|
|
240
|
+
await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: content.contentId });
|
|
241
|
+
return { cid, status: "accepted", kind: "content-op", chainId: content.contentId };
|
|
187
242
|
}
|
|
188
243
|
const previousCID = payload["previousOperationCID"];
|
|
189
244
|
if (typeof previousCID !== "string") {
|
|
@@ -198,24 +253,31 @@ var ingestContentOp = async (jwsToken, store) => {
|
|
|
198
253
|
const chain = await store.getContentChain(prevOp.chainId);
|
|
199
254
|
if (!chain)
|
|
200
255
|
return { cid, status: "rejected", error: `content chain not found: ${prevOp.chainId}` };
|
|
256
|
+
const creatorIdentity = await store.getIdentityChain(chain.state.creatorDID);
|
|
257
|
+
if (creatorIdentity?.state.isDeleted) {
|
|
258
|
+
return { cid, status: "rejected", error: "content creator identity is deleted" };
|
|
259
|
+
}
|
|
201
260
|
if (chain.state.headCID !== previousCID) {
|
|
202
261
|
return { cid, status: "rejected", error: "chain has diverged (first-seen-wins)" };
|
|
203
262
|
}
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
263
|
+
const extResult = await verifyContentExtensionFromTrustedState({
|
|
264
|
+
currentState: chain.state,
|
|
265
|
+
lastCreatedAt: chain.lastCreatedAt,
|
|
266
|
+
newOp: jwsToken,
|
|
207
267
|
resolveKey,
|
|
208
268
|
enforceAuthorization: true
|
|
209
269
|
});
|
|
210
270
|
const updated = {
|
|
211
|
-
contentId:
|
|
212
|
-
genesisCID:
|
|
213
|
-
log:
|
|
214
|
-
|
|
271
|
+
contentId: chain.contentId,
|
|
272
|
+
genesisCID: chain.genesisCID,
|
|
273
|
+
log: [...chain.log, jwsToken],
|
|
274
|
+
lastCreatedAt: extResult.createdAt,
|
|
275
|
+
state: extResult.state
|
|
215
276
|
};
|
|
216
277
|
await store.putContentChain(updated);
|
|
217
|
-
await store.putOperation({ cid, jwsToken, chainType: "content", chainId:
|
|
218
|
-
|
|
278
|
+
await store.putOperation({ cid, jwsToken, chainType: "content", chainId: chain.contentId });
|
|
279
|
+
await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: chain.contentId });
|
|
280
|
+
return { cid, status: "accepted", kind: "content-op", chainId: chain.contentId };
|
|
219
281
|
};
|
|
220
282
|
var ingestBeacon = async (jwsToken, store) => {
|
|
221
283
|
const resolveKey = createKeyResolver(store);
|
|
@@ -228,6 +290,10 @@ var ingestBeacon = async (jwsToken, store) => {
|
|
|
228
290
|
}
|
|
229
291
|
const did = verified.payload.did;
|
|
230
292
|
const cid = verified.beaconCID;
|
|
293
|
+
const identity = await store.getIdentityChain(did);
|
|
294
|
+
if (identity?.state.isDeleted) {
|
|
295
|
+
return { cid, status: "rejected", error: "identity is deleted" };
|
|
296
|
+
}
|
|
231
297
|
const existing = await store.getBeacon(did);
|
|
232
298
|
if (existing) {
|
|
233
299
|
const existingTime = new Date(existing.state.payload.createdAt).getTime();
|
|
@@ -237,72 +303,96 @@ var ingestBeacon = async (jwsToken, store) => {
|
|
|
237
303
|
}
|
|
238
304
|
}
|
|
239
305
|
await store.putBeacon({ did, jwsToken, beaconCID: cid, state: verified });
|
|
306
|
+
await store.putOperation({ cid, jwsToken, chainType: "beacon", chainId: did });
|
|
307
|
+
await store.appendToLog({ cid, jwsToken, kind: "beacon", chainId: did });
|
|
240
308
|
return { cid, status: "accepted", kind: "beacon", chainId: did };
|
|
241
309
|
};
|
|
242
|
-
var
|
|
243
|
-
const decoded = decodeJwsUnsafe(jwsToken);
|
|
244
|
-
if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
|
|
245
|
-
const payload = decoded.payload;
|
|
246
|
-
const encoded = await dagCborCanonicalEncode(payload);
|
|
247
|
-
const operationCID = encoded.cid.toString();
|
|
248
|
-
const existingOp = await store.getOperation(operationCID);
|
|
249
|
-
if (!existingOp) {
|
|
250
|
-
return { cid: operationCID, status: "rejected", error: `unknown operation: ${operationCID}` };
|
|
251
|
-
}
|
|
310
|
+
var ingestCountersign = async (jwsToken, store) => {
|
|
252
311
|
const resolveKey = createKeyResolver(store);
|
|
312
|
+
let verified;
|
|
253
313
|
try {
|
|
254
|
-
await verifyCountersignature({ jwsToken,
|
|
314
|
+
verified = await verifyCountersignature({ jwsToken, resolveKey });
|
|
255
315
|
} catch (err) {
|
|
256
316
|
const message = err instanceof Error ? err.message : "verification failed";
|
|
257
|
-
return { cid:
|
|
258
|
-
}
|
|
259
|
-
await store.addCountersignature(operationCID, jwsToken);
|
|
260
|
-
return {
|
|
261
|
-
cid: operationCID,
|
|
262
|
-
status: "accepted",
|
|
263
|
-
kind: "countersig",
|
|
264
|
-
chainId: existingOp.chainId
|
|
265
|
-
};
|
|
266
|
-
};
|
|
267
|
-
var ingestBeaconCountersig = async (jwsToken, store) => {
|
|
268
|
-
const decoded = decodeJwsUnsafe(jwsToken);
|
|
269
|
-
if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
|
|
270
|
-
const payload = decoded.payload;
|
|
271
|
-
const encoded = await dagCborCanonicalEncode(payload);
|
|
272
|
-
const beaconCID = encoded.cid.toString();
|
|
273
|
-
const beaconDID = typeof payload["did"] === "string" ? payload["did"] : null;
|
|
274
|
-
if (!beaconDID) {
|
|
275
|
-
return { cid: beaconCID, status: "rejected", error: "missing beacon DID" };
|
|
317
|
+
return { cid: "", status: "rejected", error: message };
|
|
276
318
|
}
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
319
|
+
const cid = verified.countersignCID;
|
|
320
|
+
const { witnessDID, targetCID } = verified;
|
|
321
|
+
const existing = await store.getOperation(cid);
|
|
322
|
+
if (existing) {
|
|
323
|
+
if (existing.jwsToken !== jwsToken) {
|
|
324
|
+
return {
|
|
325
|
+
cid,
|
|
326
|
+
status: "rejected",
|
|
327
|
+
error: "countersign already exists with a different signature"
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { cid, status: "accepted", kind: "countersign", chainId: targetCID };
|
|
280
331
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
332
|
+
const targetOp = await store.getOperation(targetCID);
|
|
333
|
+
if (!targetOp) {
|
|
334
|
+
return { cid, status: "rejected", error: `unknown target operation: ${targetCID}` };
|
|
335
|
+
}
|
|
336
|
+
let targetAuthorDID = null;
|
|
337
|
+
if (targetOp.chainType === "identity") {
|
|
338
|
+
targetAuthorDID = targetOp.chainId;
|
|
339
|
+
} else {
|
|
340
|
+
const targetDecoded = decodeJwsUnsafe(targetOp.jwsToken);
|
|
341
|
+
if (targetDecoded) {
|
|
342
|
+
const targetPayload = targetDecoded.payload;
|
|
343
|
+
targetAuthorDID = typeof targetPayload["did"] === "string" ? targetPayload["did"] : null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (targetAuthorDID && witnessDID === targetAuthorDID) {
|
|
347
|
+
return { cid, status: "rejected", error: "witness DID must differ from target author DID" };
|
|
348
|
+
}
|
|
349
|
+
const witnessIdentity = await store.getIdentityChain(witnessDID);
|
|
350
|
+
if (witnessIdentity?.state.isDeleted) {
|
|
351
|
+
return { cid, status: "rejected", error: "witness identity is deleted" };
|
|
352
|
+
}
|
|
353
|
+
const existingCountersigns = await store.getCountersignatures(targetCID);
|
|
354
|
+
for (const csJws of existingCountersigns) {
|
|
355
|
+
const csDecoded = decodeJwsUnsafe(csJws);
|
|
356
|
+
if (!csDecoded) continue;
|
|
357
|
+
const csPayload = csDecoded.payload;
|
|
358
|
+
if (csPayload["did"] === witnessDID) {
|
|
359
|
+
return { cid, status: "accepted", kind: "countersign", chainId: targetCID };
|
|
360
|
+
}
|
|
287
361
|
}
|
|
362
|
+
await store.putOperation({ cid, jwsToken, chainType: "countersign", chainId: targetCID });
|
|
363
|
+
await store.addCountersignature(targetCID, jwsToken);
|
|
364
|
+
await store.appendToLog({ cid, jwsToken, kind: "countersign", chainId: targetCID });
|
|
365
|
+
return { cid, status: "accepted", kind: "countersign", chainId: targetCID };
|
|
366
|
+
};
|
|
367
|
+
var ingestArtifact = async (jwsToken, store) => {
|
|
288
368
|
const resolveKey = createKeyResolver(store);
|
|
369
|
+
let verified;
|
|
289
370
|
try {
|
|
290
|
-
await
|
|
291
|
-
jwsToken,
|
|
292
|
-
expectedCID: beaconCID,
|
|
293
|
-
resolveKey
|
|
294
|
-
});
|
|
371
|
+
verified = await verifyArtifact({ jwsToken, resolveKey });
|
|
295
372
|
} catch (err) {
|
|
296
373
|
const message = err instanceof Error ? err.message : "verification failed";
|
|
297
|
-
return { cid:
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
374
|
+
return { cid: "", status: "rejected", error: message };
|
|
375
|
+
}
|
|
376
|
+
const cid = verified.artifactCID;
|
|
377
|
+
const did = verified.payload.did;
|
|
378
|
+
const existing = await store.getOperation(cid);
|
|
379
|
+
if (existing) {
|
|
380
|
+
if (existing.jwsToken !== jwsToken) {
|
|
381
|
+
return {
|
|
382
|
+
cid,
|
|
383
|
+
status: "rejected",
|
|
384
|
+
error: "artifact already exists with a different signature"
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { cid, status: "accepted", kind: "artifact", chainId: did };
|
|
388
|
+
}
|
|
389
|
+
const identity = await store.getIdentityChain(did);
|
|
390
|
+
if (identity?.state.isDeleted) {
|
|
391
|
+
return { cid, status: "rejected", error: "identity is deleted" };
|
|
392
|
+
}
|
|
393
|
+
await store.putOperation({ cid, jwsToken, chainType: "artifact", chainId: did });
|
|
394
|
+
await store.appendToLog({ cid, jwsToken, kind: "artifact", chainId: did });
|
|
395
|
+
return { cid, status: "accepted", kind: "artifact", chainId: did };
|
|
306
396
|
};
|
|
307
397
|
var dependencySort = (ops) => {
|
|
308
398
|
const buckets = /* @__PURE__ */ new Map();
|
|
@@ -378,11 +468,11 @@ var ingestOperations = async (tokens, store) => {
|
|
|
378
468
|
case "beacon":
|
|
379
469
|
result = await ingestBeacon(op.jwsToken, store);
|
|
380
470
|
break;
|
|
381
|
-
case "
|
|
382
|
-
result = await
|
|
471
|
+
case "countersign":
|
|
472
|
+
result = await ingestCountersign(op.jwsToken, store);
|
|
383
473
|
break;
|
|
384
|
-
case "
|
|
385
|
-
result = await
|
|
474
|
+
case "artifact":
|
|
475
|
+
result = await ingestArtifact(op.jwsToken, store);
|
|
386
476
|
break;
|
|
387
477
|
default:
|
|
388
478
|
result = { cid: "", status: "rejected", error: "unrecognized operation type" };
|
|
@@ -399,7 +489,65 @@ var ingestOperations = async (tokens, store) => {
|
|
|
399
489
|
return indexedResults.sort((a, b) => a.index - b.index).map((r) => r.result);
|
|
400
490
|
};
|
|
401
491
|
|
|
492
|
+
// src/bootstrap.ts
|
|
493
|
+
var bootstrapRelayIdentity = async (store) => {
|
|
494
|
+
const keypair = createNewEd25519Keypair();
|
|
495
|
+
const keyId = generateId("key");
|
|
496
|
+
const multibase = encodeEd25519Multikey(keypair.publicKey);
|
|
497
|
+
const signer = async (msg) => signPayloadEd25519(msg, keypair.privateKey);
|
|
498
|
+
const key = { id: keyId, type: "Multikey", publicKeyMultibase: multibase };
|
|
499
|
+
const identityOp = {
|
|
500
|
+
version: 1,
|
|
501
|
+
type: "create",
|
|
502
|
+
authKeys: [key],
|
|
503
|
+
assertKeys: [key],
|
|
504
|
+
controllerKeys: [key],
|
|
505
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
506
|
+
};
|
|
507
|
+
const { jwsToken: identityJws } = await signIdentityOperation({
|
|
508
|
+
operation: identityOp,
|
|
509
|
+
signer,
|
|
510
|
+
keyId
|
|
511
|
+
});
|
|
512
|
+
const [identityResult] = await ingestOperations([identityJws], store);
|
|
513
|
+
if (!identityResult || identityResult.status !== "accepted" || !identityResult.chainId) {
|
|
514
|
+
throw new Error(`failed to bootstrap relay identity: ${identityResult?.error ?? "unknown"}`);
|
|
515
|
+
}
|
|
516
|
+
const did = identityResult.chainId;
|
|
517
|
+
const profilePayload = {
|
|
518
|
+
version: 1,
|
|
519
|
+
type: "artifact",
|
|
520
|
+
did,
|
|
521
|
+
content: {
|
|
522
|
+
$schema: "https://schemas.dfos.com/profile/v1",
|
|
523
|
+
name: "DFOS Relay"
|
|
524
|
+
},
|
|
525
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
526
|
+
};
|
|
527
|
+
const kid = `${did}#${keyId}`;
|
|
528
|
+
const { jwsToken: profileArtifactJws } = await signArtifact({
|
|
529
|
+
payload: profilePayload,
|
|
530
|
+
signer,
|
|
531
|
+
kid
|
|
532
|
+
});
|
|
533
|
+
const [artifactResult] = await ingestOperations([profileArtifactJws], store);
|
|
534
|
+
if (!artifactResult || artifactResult.status !== "accepted") {
|
|
535
|
+
throw new Error(
|
|
536
|
+
`failed to ingest relay profile artifact: ${artifactResult?.error ?? "unknown"}`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return { did, profileArtifactJws };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/relay.ts
|
|
543
|
+
import { VC_TYPE_CONTENT_READ, verifyCredential } from "@metalabel/dfos-protocol/credentials";
|
|
544
|
+
import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
|
|
545
|
+
import { Hono } from "hono";
|
|
546
|
+
import { z } from "zod";
|
|
547
|
+
|
|
402
548
|
// src/auth.ts
|
|
549
|
+
import { verifyAuthToken } from "@metalabel/dfos-protocol/credentials";
|
|
550
|
+
import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
|
|
403
551
|
var authenticateRequest = async (authHeader, relayDID, store) => {
|
|
404
552
|
if (!authHeader) return null;
|
|
405
553
|
if (!authHeader.startsWith("Bearer ")) return null;
|
|
@@ -427,14 +575,21 @@ var authenticateRequest = async (authHeader, relayDID, store) => {
|
|
|
427
575
|
var IngestBody = z.object({
|
|
428
576
|
operations: z.array(z.string()).min(1).max(100)
|
|
429
577
|
});
|
|
430
|
-
var createRelay = (options) => {
|
|
431
|
-
const {
|
|
578
|
+
var createRelay = async (options) => {
|
|
579
|
+
const { store } = options;
|
|
580
|
+
const contentEnabled = options.content !== false;
|
|
581
|
+
const identity = options.identity ?? await bootstrapRelayIdentity(store);
|
|
582
|
+
const relayDID = identity.did;
|
|
583
|
+
const profileArtifactJws = identity.profileArtifactJws;
|
|
432
584
|
const app = new Hono();
|
|
433
585
|
app.get("/.well-known/dfos-relay", (c) => {
|
|
434
586
|
return c.json({
|
|
435
587
|
did: relayDID,
|
|
436
588
|
protocol: "dfos-web-relay",
|
|
437
|
-
version: "0.1.0"
|
|
589
|
+
version: "0.1.0",
|
|
590
|
+
proof: true,
|
|
591
|
+
content: contentEnabled,
|
|
592
|
+
profile: profileArtifactJws
|
|
438
593
|
});
|
|
439
594
|
});
|
|
440
595
|
app.post("/operations", async (c) => {
|
|
@@ -462,16 +617,54 @@ var createRelay = (options) => {
|
|
|
462
617
|
chainId: op.chainId
|
|
463
618
|
});
|
|
464
619
|
});
|
|
620
|
+
app.get("/identities/:did/log", async (c) => {
|
|
621
|
+
const did = c.req.param("did");
|
|
622
|
+
const chain = await store.getIdentityChain(did);
|
|
623
|
+
if (!chain) return c.json({ error: "not found" }, 404);
|
|
624
|
+
const after = c.req.query("after");
|
|
625
|
+
const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
|
|
626
|
+
const entries = chain.log.map((jws) => {
|
|
627
|
+
const decoded = decodeJwsUnsafe3(jws);
|
|
628
|
+
return { cid: decoded?.header.cid || "", jwsToken: jws };
|
|
629
|
+
});
|
|
630
|
+
let startIdx = 0;
|
|
631
|
+
if (after) {
|
|
632
|
+
const idx = entries.findIndex((e) => e.cid === after);
|
|
633
|
+
startIdx = idx >= 0 ? idx + 1 : entries.length;
|
|
634
|
+
}
|
|
635
|
+
const page = entries.slice(startIdx, startIdx + limit);
|
|
636
|
+
const cursor = page.length === limit ? page[page.length - 1].cid : null;
|
|
637
|
+
return c.json({ entries: page, cursor });
|
|
638
|
+
});
|
|
465
639
|
app.get("/identities/:did{.+}", async (c) => {
|
|
466
640
|
const did = c.req.param("did");
|
|
467
641
|
const chain = await store.getIdentityChain(did);
|
|
468
642
|
if (!chain) return c.json({ error: "not found" }, 404);
|
|
469
643
|
return c.json({
|
|
470
644
|
did: chain.did,
|
|
471
|
-
|
|
645
|
+
headCID: chain.headCID,
|
|
472
646
|
state: chain.state
|
|
473
647
|
});
|
|
474
648
|
});
|
|
649
|
+
app.get("/content/:contentId/log", async (c) => {
|
|
650
|
+
const contentId = c.req.param("contentId");
|
|
651
|
+
const chain = await store.getContentChain(contentId);
|
|
652
|
+
if (!chain) return c.json({ error: "not found" }, 404);
|
|
653
|
+
const after = c.req.query("after");
|
|
654
|
+
const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
|
|
655
|
+
const entries = chain.log.map((jws) => {
|
|
656
|
+
const decoded = decodeJwsUnsafe3(jws);
|
|
657
|
+
return { cid: decoded?.header.cid || "", jwsToken: jws };
|
|
658
|
+
});
|
|
659
|
+
let startIdx = 0;
|
|
660
|
+
if (after) {
|
|
661
|
+
const idx = entries.findIndex((e) => e.cid === after);
|
|
662
|
+
startIdx = idx >= 0 ? idx + 1 : entries.length;
|
|
663
|
+
}
|
|
664
|
+
const page = entries.slice(startIdx, startIdx + limit);
|
|
665
|
+
const cursor = page.length === limit ? page[page.length - 1].cid : null;
|
|
666
|
+
return c.json({ entries: page, cursor });
|
|
667
|
+
});
|
|
475
668
|
app.get("/content/:contentId", async (c) => {
|
|
476
669
|
const contentId = c.req.param("contentId");
|
|
477
670
|
const chain = await store.getContentChain(contentId);
|
|
@@ -479,7 +672,7 @@ var createRelay = (options) => {
|
|
|
479
672
|
return c.json({
|
|
480
673
|
contentId: chain.contentId,
|
|
481
674
|
genesisCID: chain.genesisCID,
|
|
482
|
-
|
|
675
|
+
headCID: chain.state.headCID,
|
|
483
676
|
state: chain.state
|
|
484
677
|
});
|
|
485
678
|
});
|
|
@@ -512,7 +705,14 @@ var createRelay = (options) => {
|
|
|
512
705
|
payload: beacon.state.payload
|
|
513
706
|
});
|
|
514
707
|
});
|
|
708
|
+
app.get("/log", async (c) => {
|
|
709
|
+
const afterParam = c.req.query("after");
|
|
710
|
+
const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
|
|
711
|
+
const result = await store.readLog(afterParam ? { after: afterParam, limit } : { limit });
|
|
712
|
+
return c.json(result);
|
|
713
|
+
});
|
|
515
714
|
app.put("/content/:contentId/blob/:operationCID", async (c) => {
|
|
715
|
+
if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
|
|
516
716
|
const contentId = c.req.param("contentId");
|
|
517
717
|
const operationCID = c.req.param("operationCID");
|
|
518
718
|
const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
|
|
@@ -550,6 +750,7 @@ var createRelay = (options) => {
|
|
|
550
750
|
return c.json({ status: "stored", contentId, documentCID, operationCID });
|
|
551
751
|
});
|
|
552
752
|
app.get("/content/:contentId/blob", async (c) => {
|
|
753
|
+
if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
|
|
553
754
|
return await readBlob({
|
|
554
755
|
contentId: c.req.param("contentId"),
|
|
555
756
|
ref: "head",
|
|
@@ -560,6 +761,7 @@ var createRelay = (options) => {
|
|
|
560
761
|
});
|
|
561
762
|
});
|
|
562
763
|
app.get("/content/:contentId/blob/:ref", async (c) => {
|
|
764
|
+
if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
|
|
563
765
|
return await readBlob({
|
|
564
766
|
contentId: c.req.param("contentId"),
|
|
565
767
|
ref: c.req.param("ref"),
|
|
@@ -627,6 +829,10 @@ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) =>
|
|
|
627
829
|
if (vcIssuerDID !== chain.state.creatorDID) {
|
|
628
830
|
throw new Error("credential must be issued by the chain creator");
|
|
629
831
|
}
|
|
832
|
+
const issuerIdentity = await store.getIdentityChain(vcIssuerDID);
|
|
833
|
+
if (issuerIdentity?.state.isDeleted) {
|
|
834
|
+
throw new Error("credential issuer identity is deleted");
|
|
835
|
+
}
|
|
630
836
|
const creatorKey = await resolveKey(vcHeader.kid);
|
|
631
837
|
const credential = verifyCredential({
|
|
632
838
|
token: credHeader,
|
|
@@ -657,6 +863,7 @@ var MemoryRelayStore = class {
|
|
|
657
863
|
beacons = /* @__PURE__ */ new Map();
|
|
658
864
|
blobs = /* @__PURE__ */ new Map();
|
|
659
865
|
countersignatures = /* @__PURE__ */ new Map();
|
|
866
|
+
operationLog = [];
|
|
660
867
|
async getOperation(cid) {
|
|
661
868
|
return this.operations.get(cid);
|
|
662
869
|
}
|
|
@@ -707,9 +914,24 @@ var MemoryRelayStore = class {
|
|
|
707
914
|
existing.push(jwsToken);
|
|
708
915
|
this.countersignatures.set(operationCID, existing);
|
|
709
916
|
}
|
|
917
|
+
async appendToLog(entry) {
|
|
918
|
+
this.operationLog.push(entry);
|
|
919
|
+
}
|
|
920
|
+
async readLog(params) {
|
|
921
|
+
let startIdx = 0;
|
|
922
|
+
if (params.after) {
|
|
923
|
+
const idx = this.operationLog.findIndex((e) => e.cid === params.after);
|
|
924
|
+
if (idx >= 0) startIdx = idx + 1;
|
|
925
|
+
else startIdx = this.operationLog.length;
|
|
926
|
+
}
|
|
927
|
+
const entries = this.operationLog.slice(startIdx, startIdx + params.limit);
|
|
928
|
+
const cursor = entries.length === params.limit ? entries[entries.length - 1].cid : null;
|
|
929
|
+
return { entries, cursor };
|
|
930
|
+
}
|
|
710
931
|
};
|
|
711
932
|
export {
|
|
712
933
|
MemoryRelayStore,
|
|
934
|
+
bootstrapRelayIdentity,
|
|
713
935
|
createCurrentKeyResolver,
|
|
714
936
|
createKeyResolver,
|
|
715
937
|
createRelay,
|
package/dist/serve.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ interface ServeOptions {
|
|
|
12
12
|
* import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
|
|
13
13
|
* import { serve } from '@metalabel/dfos-web-relay/node';
|
|
14
14
|
*
|
|
15
|
-
* const relay = createRelay({
|
|
15
|
+
* const relay = await createRelay({ store: new MemoryRelayStore() });
|
|
16
16
|
* serve(relay, { port: 4444 });
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalabel/dfos-web-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DFOS Web Relay — verifying HTTP relay for identity chains, content chains, beacons, and content blobs",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"README.md"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"hono": "^4.12.
|
|
45
|
+
"hono": "^4.12.8",
|
|
46
46
|
"zod": "^4.3.6"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
@@ -51,13 +51,15 @@
|
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^24.10.4",
|
|
53
53
|
"tsup": "^8.5.1",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
54
|
+
"tsx": "^4.20.3",
|
|
55
|
+
"vitest": "^4.1.0",
|
|
56
|
+
"@metalabel/dfos-protocol": "0.5.0"
|
|
56
57
|
},
|
|
57
58
|
"scripts": {
|
|
58
59
|
"build": "tsup",
|
|
59
60
|
"clean": "rm -rf dist",
|
|
60
61
|
"typecheck": "tsc --noEmit",
|
|
61
|
-
"test": "vitest run"
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:conformance": "./tests/run-conformance.sh"
|
|
62
64
|
}
|
|
63
65
|
}
|