@pdsjs/spaces 1.0.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/src/mac.js ADDED
@@ -0,0 +1,107 @@
1
+ // @pdsjs/spaces/mac - commit context encoding and deniable-signature MAC.
2
+
3
+ export const COMMIT_VERSION = 1;
4
+
5
+ const DOMAIN_PREFIX = new TextEncoder().encode('atproto-space-v1');
6
+
7
+ /**
8
+ * ctx = "atproto-space-v1"
9
+ * || uint16be(len(space)) || space
10
+ * || uint16be(len(author)) || author
11
+ * || uint16be(len(rev)) || rev
12
+ * || uint16be(len(ikm)) || ikm
13
+ *
14
+ * Length prefixes are big-endian per the TLS variable-length vector convention,
15
+ * deliberately the opposite byte order from the set hash's lanes.
16
+ *
17
+ * @param {{space: string, author: string, rev: string}} ctx
18
+ * @param {Uint8Array} ikm
19
+ * @returns {Uint8Array}
20
+ */
21
+ export function encodeCommitCtx(ctx, ikm) {
22
+ const encoder = new TextEncoder();
23
+ const fields = [
24
+ encoder.encode(ctx.space),
25
+ encoder.encode(ctx.author),
26
+ encoder.encode(ctx.rev),
27
+ ikm,
28
+ ];
29
+
30
+ let size = DOMAIN_PREFIX.length;
31
+ for (const field of fields) {
32
+ if (field.length > 0xffff) {
33
+ throw new Error('commit ctx field exceeds uint16 length prefix');
34
+ }
35
+ size += 2 + field.length;
36
+ }
37
+
38
+ const out = new Uint8Array(size);
39
+ out.set(DOMAIN_PREFIX);
40
+ let offset = DOMAIN_PREFIX.length;
41
+ for (const field of fields) {
42
+ out[offset++] = (field.length >>> 8) & 0xff;
43
+ out[offset++] = field.length & 0xff;
44
+ out.set(field, offset);
45
+ offset += field.length;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * @param {Uint8Array} key
52
+ * @param {Uint8Array} data
53
+ * @returns {Promise<Uint8Array>}
54
+ */
55
+ export async function hmacSha256(key, data) {
56
+ const cryptoKey = await crypto.subtle.importKey(
57
+ 'raw',
58
+ /** @type {BufferSource} */ (key),
59
+ { name: 'HMAC', hash: 'SHA-256' },
60
+ false,
61
+ ['sign'],
62
+ );
63
+ const sig = await crypto.subtle.sign(
64
+ 'HMAC',
65
+ cryptoKey,
66
+ /** @type {BufferSource} */ (data),
67
+ );
68
+ return new Uint8Array(sig);
69
+ }
70
+
71
+ /**
72
+ * HKDF-Expand (RFC 5869 §2.3) with SHA-256 and L = 32, matching the reference's
73
+ * `expand(sha256, ikm, info, 32)`. `ikm` is used directly as the PRK — there is
74
+ * no extract step, which is why crypto.subtle's HKDF cannot be used here. With
75
+ * L equal to the hash length this is exactly one block.
76
+ *
77
+ * @param {Uint8Array} ikm
78
+ * @param {Uint8Array} info
79
+ * @returns {Promise<Uint8Array>}
80
+ */
81
+ export function hkdfExpandSha256(ikm, info) {
82
+ const block = new Uint8Array(info.length + 1);
83
+ block.set(info);
84
+ block[info.length] = 0x01;
85
+ return hmacSha256(ikm, block);
86
+ }
87
+
88
+ /**
89
+ * @param {Uint8Array} ikm
90
+ * @param {Uint8Array} ctxBytes
91
+ * @param {Uint8Array} hash
92
+ * @returns {Promise<Uint8Array>}
93
+ */
94
+ export async function computeMac(ikm, ctxBytes, hash) {
95
+ return hmacSha256(await hkdfExpandSha256(ikm, ctxBytes), hash);
96
+ }
97
+
98
+ /**
99
+ * @param {Uint8Array} a
100
+ * @param {Uint8Array} b
101
+ * @returns {boolean}
102
+ */
103
+ export function bytesEqual(a, b) {
104
+ if (a.length !== b.length) return false;
105
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
106
+ return true;
107
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @returns {import('@pdsjs/core/ports').SpaceStoragePort}
3
+ */
4
+ export declare function createMemorySpaceStorage(): import('@pdsjs/core/ports').SpaceStoragePort;
@@ -0,0 +1,244 @@
1
+ // @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort.
2
+ //
3
+ // Backs unit tests and gives handler tests a store with no database. Keyed
4
+ // exactly like the SQL adapters so the shared conformance suite exercises the
5
+ // same behaviour everywhere.
6
+
7
+ /**
8
+ * Keyset pagination over a sorted key list.
9
+ * @template T
10
+ * @param {string[]} sortedKeys
11
+ * @param {string|null} cursor - exclusive lower bound
12
+ * @param {number} limit
13
+ * @param {(key: string) => T} map
14
+ * @returns {{items: T[], cursor: string|null}}
15
+ */
16
+ function page(sortedKeys, cursor, limit, map) {
17
+ const start = cursor ? sortedKeys.findIndex((k) => k > cursor) : 0;
18
+ if (start === -1) return { items: [], cursor: null };
19
+ const slice = sortedKeys.slice(start, start + limit);
20
+ return {
21
+ items: slice.map(map),
22
+ // A full page always yields a cursor, even if it happened to be the last
23
+ // one. The next call then returns empty with a null cursor. This matches
24
+ // `WHERE key > ? LIMIT n` in the SQL adapters — do not "optimise" it to
25
+ // return null on the final full page, or the two diverge.
26
+ cursor: slice.length === limit ? slice[slice.length - 1] : null,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * @returns {import('@pdsjs/core/ports').SpaceStoragePort}
32
+ */
33
+ export function createMemorySpaceStorage() {
34
+ /** @type {Map<string, import('@pdsjs/core/ports').SpaceRow>} */
35
+ const spaces = new Map();
36
+ /** @type {Map<string, Set<string>>} space uri -> dids */
37
+ const members = new Map();
38
+ /** @type {Map<string, Map<string, import('@pdsjs/core/ports').SpaceRecordRow>>} */
39
+ const records = new Map();
40
+ /** @type {Map<string, {setHash: Uint8Array|null, rev: string|null}>} */
41
+ const repos = new Map();
42
+ /** @type {Map<string, import('@pdsjs/core/ports').SpaceOpRow[]>} */
43
+ const oplog = new Map();
44
+ /** @type {Map<string, Map<string, import('@pdsjs/core/ports').SpaceWriterRow>>} */
45
+ const writers = new Map();
46
+ /** @type {Map<string, Map<string, import('@pdsjs/core/ports').SpaceRecipientRow>>} */
47
+ const recipients = new Map();
48
+
49
+ return {
50
+ async getSpace(uri) {
51
+ const row = spaces.get(uri);
52
+ return row ? { ...row, appAllowed: [...row.appAllowed] } : null;
53
+ },
54
+
55
+ async putSpace(space) {
56
+ spaces.set(space.uri, { ...space, appAllowed: [...space.appAllowed] });
57
+ },
58
+
59
+ async deleteSpace(uri, deletedAt) {
60
+ const row = spaces.get(uri);
61
+ if (row) spaces.set(uri, { ...row, deletedAt });
62
+ },
63
+
64
+ async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) {
65
+ let keys = [...spaces.keys()].sort();
66
+ if (type || did) {
67
+ keys = keys.filter((uri) => {
68
+ // Filter on the stored components, not by re-parsing the URI: the
69
+ // domain supplies them, and every adapter must agree on the result.
70
+ const row = /** @type {import('@pdsjs/core/ports').SpaceRow} */ (
71
+ spaces.get(uri)
72
+ );
73
+ if (type && row.spaceType !== type) return false;
74
+ if (did && row.spaceDid !== did) return false;
75
+ return true;
76
+ });
77
+ }
78
+ const { items, cursor: next } = page(keys, cursor, limit, (uri) => {
79
+ // uri comes from spaces.keys(), so this lookup is always present.
80
+ const row = /** @type {import('@pdsjs/core/ports').SpaceRow} */ (
81
+ spaces.get(uri)
82
+ );
83
+ return { ...row, appAllowed: [...row.appAllowed] };
84
+ });
85
+ return { spaces: items, cursor: next };
86
+ },
87
+
88
+ async addMember(space, did) {
89
+ let set = members.get(space);
90
+ if (!set) {
91
+ set = new Set();
92
+ members.set(space, set);
93
+ }
94
+ set.add(did);
95
+ },
96
+
97
+ async removeMember(space, did) {
98
+ members.get(space)?.delete(did);
99
+ },
100
+
101
+ async listMembers(space, cursor, limit) {
102
+ const keys = [...(members.get(space) ?? [])].sort();
103
+ const { items, cursor: next } = page(keys, cursor, limit, (d) => d);
104
+ return { dids: items, cursor: next };
105
+ },
106
+
107
+ async isMember(space, did) {
108
+ return members.get(space)?.has(did) ?? false;
109
+ },
110
+
111
+ async getSpaceRecord(space, collection, rkey) {
112
+ const row = records.get(space)?.get(`${collection}/${rkey}`);
113
+ return row ? { ...row, value: new Uint8Array(row.value) } : null;
114
+ },
115
+
116
+ async getSpaceRecordCid(space, collection, rkey) {
117
+ return records.get(space)?.get(`${collection}/${rkey}`)?.cid ?? null;
118
+ },
119
+
120
+ async listSpaceRecords(space, collection, cursor, limit) {
121
+ const inSpace = records.get(space) ?? new Map();
122
+ const keys = [...inSpace.values()]
123
+ .filter((r) => r.collection === collection)
124
+ .map((r) => r.rkey)
125
+ .sort();
126
+ const { items, cursor: next } = page(keys, cursor, limit, (rkey) => {
127
+ const row = inSpace.get(`${collection}/${rkey}`);
128
+ return { ...row, value: new Uint8Array(row.value) };
129
+ });
130
+ return { records: items, cursor: next };
131
+ },
132
+
133
+ async listAllSpaceRecords(space) {
134
+ const inSpace = records.get(space) ?? new Map();
135
+ return [...inSpace.keys()].sort().map((key) => {
136
+ const row = inSpace.get(key);
137
+ return { collection: row.collection, rkey: row.rkey, cid: row.cid };
138
+ });
139
+ },
140
+
141
+ async commitSpaceWrite(space, { writes, setHash, rev, indexedAt }) {
142
+ // Validate everything before mutating: this method is atomic.
143
+ for (const w of writes) {
144
+ if (w.action !== 'delete' && !(w.value instanceof Uint8Array)) {
145
+ throw new Error(
146
+ `space write value must be Uint8Array: ${w.collection}/${w.rkey}`,
147
+ );
148
+ }
149
+ }
150
+
151
+ let inSpace = records.get(space);
152
+ if (!inSpace) {
153
+ inSpace = new Map();
154
+ records.set(space, inSpace);
155
+ }
156
+ const ops = oplog.get(space) ?? [];
157
+
158
+ writes.forEach((w, idx) => {
159
+ const key = `${w.collection}/${w.rkey}`;
160
+ if (w.action === 'delete') {
161
+ inSpace.delete(key);
162
+ } else {
163
+ inSpace.set(key, {
164
+ collection: w.collection,
165
+ rkey: w.rkey,
166
+ cid: /** @type {string} */ (w.cid),
167
+ value: new Uint8Array(/** @type {Uint8Array} */ (w.value)),
168
+ repoRev: rev,
169
+ indexedAt,
170
+ });
171
+ }
172
+ ops.push({
173
+ rev,
174
+ idx,
175
+ action: w.action,
176
+ collection: w.collection,
177
+ rkey: w.rkey,
178
+ cid: w.cid,
179
+ prev: w.prev,
180
+ });
181
+ });
182
+
183
+ oplog.set(space, ops);
184
+ repos.set(space, { setHash: new Uint8Array(setHash), rev });
185
+ },
186
+
187
+ async getSpaceRepo(space) {
188
+ const repo = repos.get(space);
189
+ if (!repo) return null;
190
+ return {
191
+ setHash: repo.setHash ? new Uint8Array(repo.setHash) : null,
192
+ rev: repo.rev,
193
+ };
194
+ },
195
+
196
+ async listSpaceOps(space, since, limit) {
197
+ return (oplog.get(space) ?? [])
198
+ .filter((op) => (since === null ? true : op.rev > since))
199
+ .sort((a, b) =>
200
+ a.rev === b.rev ? a.idx - b.idx : a.rev < b.rev ? -1 : 1,
201
+ )
202
+ .slice(0, limit)
203
+ .map((op) => ({ ...op }));
204
+ },
205
+
206
+ async putSpaceWriter(space, did, rev, hash) {
207
+ let inSpace = writers.get(space);
208
+ if (!inSpace) {
209
+ inSpace = new Map();
210
+ writers.set(space, inSpace);
211
+ }
212
+ inSpace.set(did, { did, rev, hash: new Uint8Array(hash) });
213
+ },
214
+
215
+ async listSpaceWriters(space, cursor, limit) {
216
+ const inSpace = writers.get(space) ?? new Map();
217
+ const keys = [...inSpace.keys()].sort();
218
+ const { items, cursor: next } = page(keys, cursor, limit, (did) => {
219
+ const row = inSpace.get(did);
220
+ return { ...row, hash: new Uint8Array(row.hash) };
221
+ });
222
+ return { writers: items, cursor: next };
223
+ },
224
+
225
+ async putCredentialRecipient(
226
+ space,
227
+ serviceDid,
228
+ serviceEndpoint,
229
+ lastIssuedAt,
230
+ ) {
231
+ let inSpace = recipients.get(space);
232
+ if (!inSpace) {
233
+ inSpace = new Map();
234
+ recipients.set(space, inSpace);
235
+ }
236
+ inSpace.set(serviceDid, { serviceDid, serviceEndpoint, lastIssuedAt });
237
+ },
238
+
239
+ async listCredentialRecipients(space) {
240
+ const inSpace = recipients.get(space) ?? new Map();
241
+ return [...inSpace.keys()].sort().map((k) => ({ ...inSpace.get(k) }));
242
+ },
243
+ };
244
+ }
package/src/path.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @param {string} collection
3
+ * @param {string} rkey
4
+ * @returns {string}
5
+ */
6
+ export declare function formatRecordPath(collection: string, rkey: string): string;
7
+ /**
8
+ * @param {string} path
9
+ * @returns {{collection: string, rkey: string}}
10
+ */
11
+ export declare function parseRecordPath(path: string): {
12
+ collection: string;
13
+ rkey: string;
14
+ };
15
+ /**
16
+ * The element a record contributes to a repo's set hash.
17
+ * @param {string} collection
18
+ * @param {string} rkey
19
+ * @param {string} cid
20
+ * @returns {string}
21
+ */
22
+ export declare function formatSetHashElement(collection: string, rkey: string, cid: string): string;
package/src/path.js ADDED
@@ -0,0 +1,33 @@
1
+ // @pdsjs/spaces/path - record path and set hash element formatting.
2
+
3
+ /**
4
+ * @param {string} collection
5
+ * @param {string} rkey
6
+ * @returns {string}
7
+ */
8
+ export function formatRecordPath(collection, rkey) {
9
+ return `${collection}/${rkey}`;
10
+ }
11
+
12
+ /**
13
+ * @param {string} path
14
+ * @returns {{collection: string, rkey: string}}
15
+ */
16
+ export function parseRecordPath(path) {
17
+ const parts = path.split('/');
18
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
19
+ throw new Error(`invalid record path: ${path}`);
20
+ }
21
+ return { collection: parts[0], rkey: parts[1] };
22
+ }
23
+
24
+ /**
25
+ * The element a record contributes to a repo's set hash.
26
+ * @param {string} collection
27
+ * @param {string} rkey
28
+ * @param {string} cid
29
+ * @returns {string}
30
+ */
31
+ export function formatSetHashElement(collection, rkey, cid) {
32
+ return `${collection}/${rkey}/${cid}`;
33
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @param {Object} ctx
3
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
+ * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
5
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
+ * - the account's signing key, used to sign repo commits and space tokens
7
+ * @param {(did: string) => Promise<any>} ctx.resolveDid - DID document lookup,
8
+ * injected so this package stays dependency-free
9
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
10
+ * @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
11
+ * calls, so tests can observe them without a network
12
+ * @returns {import('@pdsjs/core/pds').Routes}
13
+ */
14
+ export declare function createSpaceRoutes(ctx: {
15
+ spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
16
+ getDid: () => Promise<string | null>;
17
+ getSigner: () => Promise<{
18
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
19
+ }>;
20
+ resolveDid: (did: string) => Promise<any>;
21
+ verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
22
+ fetch?: typeof fetch;
23
+ }): import('@pdsjs/core/pds').Routes;
package/src/routes.js ADDED
@@ -0,0 +1,42 @@
1
+ // @pdsjs/spaces/routes - the space route table.
2
+ //
3
+ // @pdsjs/core never imports this package. Instead a platform package builds the
4
+ // table and passes it to the PDS as `spaceRoutes`, which merges it into its own
5
+ // route map. With the feature off, the routes are simply absent and requests
6
+ // fall through to the normal 404 — see spec section 3.
7
+
8
+ import { createAuthRoutes } from './handlers/auth.js';
9
+ import { createManageRoutes } from './handlers/manage.js';
10
+ import { createReadRoutes } from './handlers/read.js';
11
+ import { createWriteRoutes } from './handlers/write.js';
12
+
13
+ /**
14
+ * @param {Object} ctx
15
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
16
+ * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
17
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
18
+ * - the account's signing key, used to sign repo commits and space tokens
19
+ * @param {(did: string) => Promise<any>} ctx.resolveDid - DID document lookup,
20
+ * injected so this package stays dependency-free
21
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
22
+ * @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
23
+ * calls, so tests can observe them without a network
24
+ * @returns {import('@pdsjs/core/pds').Routes}
25
+ */
26
+ export function createSpaceRoutes(ctx) {
27
+ if (!ctx?.spaceStorage) {
28
+ throw new Error('createSpaceRoutes requires a spaceStorage port');
29
+ }
30
+ if (!ctx.getDid || !ctx.getSigner) {
31
+ throw new Error('createSpaceRoutes requires getDid and getSigner');
32
+ }
33
+ if (!ctx.resolveDid || !ctx.verifier) {
34
+ throw new Error('createSpaceRoutes requires resolveDid and a verifier');
35
+ }
36
+ return {
37
+ ...createWriteRoutes(ctx),
38
+ ...createReadRoutes(ctx),
39
+ ...createAuthRoutes(ctx),
40
+ ...createManageRoutes(ctx),
41
+ };
42
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Verify a service-auth JWT presented to us.
3
+ *
4
+ * @param {Object} opts
5
+ * @param {string} opts.jwt
6
+ * @param {string} opts.aud - our own DID; the token must be addressed to us
7
+ * @param {string} opts.lxm - the method being called
8
+ * @param {(did: string) => Promise<any>} opts.resolveDid
9
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
10
+ * @returns {Promise<{iss: string}>}
11
+ */
12
+ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier, }: {
13
+ jwt: string;
14
+ aud: string;
15
+ lxm: string;
16
+ resolveDid: (did: string) => Promise<any>;
17
+ verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
18
+ }): Promise<{
19
+ iss: string;
20
+ }>;
21
+ /**
22
+ * Mint a service-auth JWT for an outbound call.
23
+ *
24
+ * Core has its own createServiceJwt, but it takes a CryptoKey; @pdsjs/spaces only
25
+ * ever holds a signer port, so that private key material never reaches this
26
+ * package.
27
+ *
28
+ * @param {Object} opts
29
+ * @param {string} opts.iss - our DID
30
+ * @param {string} opts.aud - the service we are calling
31
+ * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
32
+ * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
33
+ * @returns {Promise<string>}
34
+ */
35
+ export declare function createServiceAuth({ iss, aud, lxm, signer }: {
36
+ iss: string;
37
+ aud: string;
38
+ lxm: string;
39
+ signer: {
40
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
41
+ };
42
+ }): Promise<string>;
@@ -0,0 +1,153 @@
1
+ // @pdsjs/spaces/service-auth - verifying inter-service calls.
2
+ //
3
+ // notifyWrite and notifySpaceDeleted are server-to-server: a repo host tells an
4
+ // authority its user wrote, an authority tells syncers a space is gone. Both are
5
+ // authenticated with ordinary atproto service auth — a JWT signed by the
6
+ // caller's #atproto key, addressed to us, naming the method it is calling.
7
+
8
+ import { base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
9
+ import { atprotoSigningKey } from './authority.js';
10
+ import { SpaceTokenError } from './token.js';
11
+
12
+ const CLOCK_SKEW_SEC = 5;
13
+
14
+ /**
15
+ * @param {string} b64
16
+ * @returns {any}
17
+ */
18
+ function decodePart(b64) {
19
+ const pad = b64.replace(/-/g, '+').replace(/_/g, '/');
20
+ const bytes = Uint8Array.from(
21
+ atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)),
22
+ (c) => c.charCodeAt(0),
23
+ );
24
+ return JSON.parse(new TextDecoder().decode(bytes));
25
+ }
26
+
27
+ /**
28
+ * @param {string} b64
29
+ * @returns {Uint8Array}
30
+ */
31
+ function decodeSig(b64) {
32
+ const pad = b64.replace(/-/g, '+').replace(/_/g, '/');
33
+ return Uint8Array.from(
34
+ atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)),
35
+ (c) => c.charCodeAt(0),
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Verify a service-auth JWT presented to us.
41
+ *
42
+ * @param {Object} opts
43
+ * @param {string} opts.jwt
44
+ * @param {string} opts.aud - our own DID; the token must be addressed to us
45
+ * @param {string} opts.lxm - the method being called
46
+ * @param {(did: string) => Promise<any>} opts.resolveDid
47
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
48
+ * @returns {Promise<{iss: string}>}
49
+ */
50
+ export async function verifyServiceAuth({
51
+ jwt,
52
+ aud,
53
+ lxm,
54
+ resolveDid,
55
+ verifier,
56
+ }) {
57
+ const parts = typeof jwt === 'string' ? jwt.split('.') : [];
58
+ if (parts.length !== 3) {
59
+ throw new SpaceTokenError('malformed service token', 'BadJwt');
60
+ }
61
+ const [headerB64, payloadB64, sigB64] = parts;
62
+
63
+ /** @type {any} */
64
+ let payload;
65
+ try {
66
+ decodePart(headerB64);
67
+ payload = decodePart(payloadB64);
68
+ } catch {
69
+ throw new SpaceTokenError('could not parse service token', 'BadJwt');
70
+ }
71
+
72
+ const now = Math.floor(Date.now() / 1000);
73
+ if (typeof payload.exp !== 'number' || now - CLOCK_SKEW_SEC >= payload.exp) {
74
+ throw new SpaceTokenError('service token expired', 'JwtExpired');
75
+ }
76
+ if (!payload.iss) {
77
+ throw new SpaceTokenError('missing service token "iss"', 'BadJwtIss');
78
+ }
79
+ if (payload.aud !== aud) {
80
+ throw new SpaceTokenError(
81
+ 'service token is not addressed to this service',
82
+ 'BadJwtAudience',
83
+ );
84
+ }
85
+ // An lxm claim scopes a token to one method, so a token minted for one call
86
+ // cannot be replayed against another. Absent, it would be usable anywhere.
87
+ if (payload.lxm !== lxm) {
88
+ throw new SpaceTokenError(
89
+ `service token is not scoped to ${lxm}`,
90
+ 'BadJwtLexiconMethod',
91
+ );
92
+ }
93
+
94
+ const didDoc = await resolveDid(payload.iss);
95
+ if (!didDoc) {
96
+ throw new SpaceTokenError(
97
+ `could not resolve ${payload.iss}`,
98
+ 'DidNotFound',
99
+ );
100
+ }
101
+ const didKey = atprotoSigningKey(didDoc, payload.iss);
102
+ const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
103
+
104
+ let valid;
105
+ try {
106
+ valid = await verifier.verify(didKey, signingInput, decodeSig(sigB64));
107
+ } catch (err) {
108
+ throw new SpaceTokenError(
109
+ `could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
110
+ 'BadJwtSignature',
111
+ );
112
+ }
113
+ if (!valid) {
114
+ throw new SpaceTokenError(
115
+ 'invalid service token signature',
116
+ 'BadJwtSignature',
117
+ );
118
+ }
119
+
120
+ return { iss: payload.iss };
121
+ }
122
+
123
+ /**
124
+ * Mint a service-auth JWT for an outbound call.
125
+ *
126
+ * Core has its own createServiceJwt, but it takes a CryptoKey; @pdsjs/spaces only
127
+ * ever holds a signer port, so that private key material never reaches this
128
+ * package.
129
+ *
130
+ * @param {Object} opts
131
+ * @param {string} opts.iss - our DID
132
+ * @param {string} opts.aud - the service we are calling
133
+ * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
134
+ * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
135
+ * @returns {Promise<string>}
136
+ */
137
+ export async function createServiceAuth({ iss, aud, lxm, signer }) {
138
+ const now = Math.floor(Date.now() / 1000);
139
+ const header = { typ: 'JWT', alg: 'ES256' };
140
+ const payload = {
141
+ iss,
142
+ aud,
143
+ exp: now + 60,
144
+ iat: now,
145
+ jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
146
+ lxm,
147
+ };
148
+ const encode = (/** @type {object} */ o) =>
149
+ base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
150
+ const signingInput = `${encode(header)}.${encode(payload)}`;
151
+ const sig = await signer.sign(new TextEncoder().encode(signingInput));
152
+ return `${signingInput}.${base64UrlEncode(sig)}`;
153
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Build a SpaceRow from a space URI.
3
+ *
4
+ * `SpaceRow` carries `spaceDid` and `spaceType` alongside `uri` so the storage
5
+ * adapters can filter on indexed columns without parsing AT-URIs themselves
6
+ * (parsing is domain logic and lives here). Those fields are therefore
7
+ * redundant with `uri`, and nothing downstream re-checks that they agree — so
8
+ * every construction site goes through this function rather than assembling the
9
+ * object by hand.
10
+ *
11
+ * @param {string} uri - space AT-URI
12
+ * @param {Partial<Omit<import('@pdsjs/core/ports').SpaceRow, 'uri'|'spaceDid'|'spaceType'>>} [fields]
13
+ * @returns {import('@pdsjs/core/ports').SpaceRow}
14
+ */
15
+ export declare function makeSpaceRow(uri: string, fields?: Partial<Omit<import('@pdsjs/core/ports').SpaceRow, 'uri' | 'spaceDid' | 'spaceType'>>): import('@pdsjs/core/ports').SpaceRow;