@pdsjs/spaces 2.0.1 → 2.0.2
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/package.json +2 -2
- package/src/authority.d.ts +10 -0
- package/src/authority.js +18 -0
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +15 -0
- package/src/dpop.js +85 -0
- package/src/handlers/auth.d.ts +56 -1
- package/src/handlers/auth.js +112 -31
- package/src/handlers/manage.js +343 -62
- package/src/handlers/read.d.ts +2 -0
- package/src/handlers/read.js +148 -63
- package/src/handlers/write.d.ts +4 -0
- package/src/handlers/write.js +88 -24
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +38 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +7 -0
- package/src/routes.js +11 -0
- package/src/token.d.ts +11 -1
- package/src/token.js +18 -3
package/src/notify.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @pdsjs/spaces/notify - telling a space's syncers that a repo advanced.
|
|
2
|
+
//
|
|
3
|
+
// A space's writes are never broadcast, which is the point of one. So a service
|
|
4
|
+
// that wants to follow a space registers with the authority through
|
|
5
|
+
// registerNotify, and the authority forwards each write notice it learns of. A
|
|
6
|
+
// syncer that receives one holds a session and can read; the notice itself
|
|
7
|
+
// carries no records, only the rev the repo reached.
|
|
8
|
+
|
|
9
|
+
import { createServiceAuth } from './service-auth.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Forward a write notice to every service registered for this space.
|
|
13
|
+
*
|
|
14
|
+
* Best effort, and deliberately so: sync correctness rests on comparing set
|
|
15
|
+
* hashes, so a syncer that misses a notice finds out on the next one or on its
|
|
16
|
+
* own next walk. One unreachable recipient must not fail the others, and none of
|
|
17
|
+
* them may fail the write that produced the notice.
|
|
18
|
+
*
|
|
19
|
+
* The caller awaits this, which puts one round trip per recipient in front of
|
|
20
|
+
* the response. A Worker cancels a promise still running when it answers, and
|
|
21
|
+
* this package holds no execution context to hand the work to, so backgrounding
|
|
22
|
+
* it here would mean dropping notifications on Cloudflare.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} ctx
|
|
25
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
26
|
+
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
27
|
+
* @param {typeof fetch} [ctx.fetch]
|
|
28
|
+
* @param {Object} notice
|
|
29
|
+
* @param {string} notice.authorityDid - the space's authority, which signs
|
|
30
|
+
* @param {string} notice.space
|
|
31
|
+
* @param {string} notice.repo - the account whose repo advanced
|
|
32
|
+
* @param {string} notice.rev
|
|
33
|
+
* @param {Uint8Array} notice.hash - the repo's commit hash after the write
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
export async function forwardToSyncers(
|
|
37
|
+
ctx,
|
|
38
|
+
{ authorityDid, space, repo, rev, hash },
|
|
39
|
+
) {
|
|
40
|
+
let recipients;
|
|
41
|
+
try {
|
|
42
|
+
recipients = await ctx.spaceStorage.listCredentialRecipients(space);
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (recipients.length === 0) return;
|
|
47
|
+
|
|
48
|
+
const body = JSON.stringify({ space, repo, rev, hash: toJsonBytes(hash) });
|
|
49
|
+
const doFetch = ctx.fetch ?? fetch;
|
|
50
|
+
const signer = await ctx.getSigner();
|
|
51
|
+
|
|
52
|
+
await Promise.all(
|
|
53
|
+
recipients.map(async (recipient) => {
|
|
54
|
+
try {
|
|
55
|
+
const token = await createServiceAuth({
|
|
56
|
+
iss: authorityDid,
|
|
57
|
+
aud: recipient.serviceDid,
|
|
58
|
+
lxm: 'com.atproto.space.notifyWrite',
|
|
59
|
+
signer,
|
|
60
|
+
});
|
|
61
|
+
await doFetch(
|
|
62
|
+
`${recipient.serviceEndpoint}/xrpc/com.atproto.space.notifyWrite`,
|
|
63
|
+
{
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'content-type': 'application/json',
|
|
67
|
+
authorization: `Bearer ${token}`,
|
|
68
|
+
},
|
|
69
|
+
body,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
// Best effort.
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Uint8Array} bytes
|
|
81
|
+
* @returns {{$bytes: string}}
|
|
82
|
+
*/
|
|
83
|
+
function toJsonBytes(bytes) {
|
|
84
|
+
let binary = '';
|
|
85
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
86
|
+
return { $bytes: btoa(binary).replace(/=+$/, '') };
|
|
87
|
+
}
|
package/src/routes.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {Object} ctx
|
|
3
3
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
|
|
5
|
+
* from space records live in the account's ordinary blob store
|
|
6
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
7
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
4
8
|
* @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
|
|
5
9
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
6
10
|
* - the account's signing key, used to sign repo commits and space tokens
|
|
@@ -13,6 +17,9 @@
|
|
|
13
17
|
*/
|
|
14
18
|
export declare function createSpaceRoutes(ctx: {
|
|
15
19
|
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
20
|
+
blobs: import('@pdsjs/core/ports').BlobPort;
|
|
21
|
+
linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
|
|
22
|
+
unlinkBlobs: (recordUri: string) => Promise<void>;
|
|
16
23
|
getDid: () => Promise<string | null>;
|
|
17
24
|
getSigner: () => Promise<{
|
|
18
25
|
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
package/src/routes.js
CHANGED
|
@@ -13,6 +13,10 @@ import { createWriteRoutes } from './handlers/write.js';
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {Object} ctx
|
|
15
15
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
16
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
|
|
17
|
+
* from space records live in the account's ordinary blob store
|
|
18
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
19
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
16
20
|
* @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
|
|
17
21
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
18
22
|
* - the account's signing key, used to sign repo commits and space tokens
|
|
@@ -33,6 +37,13 @@ export function createSpaceRoutes(ctx) {
|
|
|
33
37
|
if (!ctx.resolveDid || !ctx.verifier) {
|
|
34
38
|
throw new Error('createSpaceRoutes requires resolveDid and a verifier');
|
|
35
39
|
}
|
|
40
|
+
// Refused rather than defaulted: without the link callbacks a space record's
|
|
41
|
+
// blobs look unreferenced, and orphan cleanup deletes them.
|
|
42
|
+
if (!ctx.blobs || !ctx.linkBlob || !ctx.unlinkBlobs) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'createSpaceRoutes requires blobs, linkBlob and unlinkBlobs',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
36
47
|
return {
|
|
37
48
|
...createWriteRoutes(ctx),
|
|
38
49
|
...createReadRoutes(ctx),
|
package/src/token.d.ts
CHANGED
|
@@ -4,18 +4,21 @@ export declare const SPACE_TOKEN_TYPES: {
|
|
|
4
4
|
kid: string;
|
|
5
5
|
expiresInSec: number;
|
|
6
6
|
requireAud: boolean;
|
|
7
|
+
requireCnf: boolean;
|
|
7
8
|
};
|
|
8
9
|
credential: {
|
|
9
10
|
typ: string;
|
|
10
11
|
kid: string;
|
|
11
12
|
expiresInSec: number;
|
|
12
13
|
requireAud: boolean;
|
|
14
|
+
requireCnf: boolean;
|
|
13
15
|
};
|
|
14
16
|
clientAttestation: {
|
|
15
17
|
typ: string;
|
|
16
18
|
kid: undefined;
|
|
17
19
|
expiresInSec: number;
|
|
18
20
|
requireAud: boolean;
|
|
21
|
+
requireCnf: boolean;
|
|
19
22
|
};
|
|
20
23
|
};
|
|
21
24
|
export declare class SpaceTokenError extends Error {
|
|
@@ -36,6 +39,12 @@ export type SpaceTokenPayload = {
|
|
|
36
39
|
iat: number;
|
|
37
40
|
exp: number;
|
|
38
41
|
jti: string;
|
|
42
|
+
/**
|
|
43
|
+
* - the key the holder must prove possession of
|
|
44
|
+
*/
|
|
45
|
+
cnf?: {
|
|
46
|
+
jkt: string;
|
|
47
|
+
};
|
|
39
48
|
};
|
|
40
49
|
export type SpaceTokenHeader = {
|
|
41
50
|
alg: string;
|
|
@@ -44,7 +53,7 @@ export type SpaceTokenHeader = {
|
|
|
44
53
|
};
|
|
45
54
|
/**
|
|
46
55
|
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
47
|
-
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
56
|
+
* @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
48
57
|
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
|
|
49
58
|
* @returns {Promise<string>}
|
|
50
59
|
*/
|
|
@@ -52,6 +61,7 @@ export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, o
|
|
|
52
61
|
iss: string;
|
|
53
62
|
sub: string;
|
|
54
63
|
aud?: string;
|
|
64
|
+
dpopJkt?: string;
|
|
55
65
|
expiresInSec?: number;
|
|
56
66
|
kid?: string;
|
|
57
67
|
alg?: string;
|
package/src/token.js
CHANGED
|
@@ -19,19 +19,26 @@ export const SPACE_TOKEN_TYPES = {
|
|
|
19
19
|
kid: '#atproto',
|
|
20
20
|
expiresInSec: 60,
|
|
21
21
|
requireAud: true,
|
|
22
|
+
requireCnf: false,
|
|
22
23
|
},
|
|
24
|
+
// An authority that publishes a dedicated `#atproto_space` key signs with it
|
|
25
|
+
// and names that key in `kid`. This server signs with the account's own
|
|
26
|
+
// `#atproto` key, which is the only key a PDS-hosted authority has.
|
|
23
27
|
credential: {
|
|
24
28
|
typ: 'atproto-space-credential+jwt',
|
|
25
|
-
kid: '#
|
|
26
|
-
// Multi-use across repo hosts until it expires, so it carries no aud.
|
|
29
|
+
kid: '#atproto',
|
|
30
|
+
// Multi-use across repo hosts until it expires, so it carries no aud. It is
|
|
31
|
+
// bound to the holder's key instead — see dpop.js.
|
|
27
32
|
expiresInSec: 7200,
|
|
28
33
|
requireAud: false,
|
|
34
|
+
requireCnf: true,
|
|
29
35
|
},
|
|
30
36
|
clientAttestation: {
|
|
31
37
|
typ: 'atproto-client-attestation+jwt',
|
|
32
38
|
kid: undefined,
|
|
33
39
|
expiresInSec: 60,
|
|
34
40
|
requireAud: true,
|
|
41
|
+
requireCnf: false,
|
|
35
42
|
},
|
|
36
43
|
};
|
|
37
44
|
|
|
@@ -57,6 +64,7 @@ export class SpaceTokenError extends Error {
|
|
|
57
64
|
* @property {number} iat
|
|
58
65
|
* @property {number} exp
|
|
59
66
|
* @property {string} jti
|
|
67
|
+
* @property {{jkt: string}} [cnf] - the key the holder must prove possession of
|
|
60
68
|
*/
|
|
61
69
|
|
|
62
70
|
/**
|
|
@@ -92,7 +100,7 @@ function decodeJsonPart(b64, part) {
|
|
|
92
100
|
|
|
93
101
|
/**
|
|
94
102
|
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
95
|
-
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
103
|
+
* @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
96
104
|
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
|
|
97
105
|
* @returns {Promise<string>}
|
|
98
106
|
*/
|
|
@@ -101,6 +109,9 @@ export async function createSpaceToken(type, opts, signer) {
|
|
|
101
109
|
if (spec.requireAud && !opts.aud) {
|
|
102
110
|
throw new SpaceTokenError(`a ${type} token requires an "aud"`);
|
|
103
111
|
}
|
|
112
|
+
if (spec.requireCnf && !opts.dpopJkt) {
|
|
113
|
+
throw new SpaceTokenError(`a ${type} token requires a "dpopJkt"`);
|
|
114
|
+
}
|
|
104
115
|
|
|
105
116
|
const iat = Math.floor(Date.now() / 1000);
|
|
106
117
|
/** @type {SpaceTokenHeader} */
|
|
@@ -113,6 +124,7 @@ export async function createSpaceToken(type, opts, signer) {
|
|
|
113
124
|
iss: opts.iss,
|
|
114
125
|
sub: opts.sub,
|
|
115
126
|
...(opts.aud ? { aud: opts.aud } : undefined),
|
|
127
|
+
...(opts.dpopJkt ? { cnf: { jkt: opts.dpopJkt } } : undefined),
|
|
116
128
|
iat,
|
|
117
129
|
exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
|
|
118
130
|
jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
|
|
@@ -163,6 +175,9 @@ export function parseSpaceToken(type, jwt) {
|
|
|
163
175
|
if (spec.requireAud && !payload.aud) {
|
|
164
176
|
throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience');
|
|
165
177
|
}
|
|
178
|
+
if (spec.requireCnf && !payload.cnf?.jkt) {
|
|
179
|
+
throw new SpaceTokenError('missing token "cnf.jkt"', 'BadJwtCnf');
|
|
180
|
+
}
|
|
166
181
|
if (type === 'clientAttestation' && payload.iss !== payload.sub) {
|
|
167
182
|
throw new SpaceTokenError(
|
|
168
183
|
'client attestation "iss" and "sub" must both be the client_id',
|