@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/LICENSE +21 -0
- package/package.json +40 -0
- package/src/authority.d.ts +47 -0
- package/src/authority.js +148 -0
- package/src/blake3.d.ts +14 -0
- package/src/blake3.js +272 -0
- package/src/car.d.ts +41 -0
- package/src/car.js +98 -0
- package/src/commit.d.ts +109 -0
- package/src/commit.js +152 -0
- package/src/handlers/auth.d.ts +67 -0
- package/src/handlers/auth.js +436 -0
- package/src/handlers/manage.d.ts +20 -0
- package/src/handlers/manage.js +508 -0
- package/src/handlers/read.d.ts +18 -0
- package/src/handlers/read.js +394 -0
- package/src/handlers/write.d.ts +18 -0
- package/src/handlers/write.js +462 -0
- package/src/index.d.ts +15 -0
- package/src/index.js +56 -0
- package/src/lthash.d.ts +24 -0
- package/src/lthash.js +86 -0
- package/src/mac.d.ts +50 -0
- package/src/mac.js +107 -0
- package/src/memory-storage.d.ts +4 -0
- package/src/memory-storage.js +244 -0
- package/src/path.d.ts +22 -0
- package/src/path.js +33 -0
- package/src/routes.d.ts +23 -0
- package/src/routes.js +42 -0
- package/src/service-auth.d.ts +42 -0
- package/src/service-auth.js +153 -0
- package/src/space-row.d.ts +15 -0
- package/src/space-row.js +33 -0
- package/src/token.d.ts +95 -0
- package/src/token.js +223 -0
- package/src/uri.d.ts +32 -0
- package/src/uri.js +89 -0
- package/src/verifier.d.ts +15 -0
- package/src/verifier.js +74 -0
- package/src/writer.d.ts +77 -0
- package/src/writer.js +195 -0
package/src/space-row.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @pdsjs/spaces/space-row - the one place a SpaceRow is constructed.
|
|
2
|
+
|
|
3
|
+
import { parseSpaceUri } from './uri.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a SpaceRow from a space URI.
|
|
7
|
+
*
|
|
8
|
+
* `SpaceRow` carries `spaceDid` and `spaceType` alongside `uri` so the storage
|
|
9
|
+
* adapters can filter on indexed columns without parsing AT-URIs themselves
|
|
10
|
+
* (parsing is domain logic and lives here). Those fields are therefore
|
|
11
|
+
* redundant with `uri`, and nothing downstream re-checks that they agree — so
|
|
12
|
+
* every construction site goes through this function rather than assembling the
|
|
13
|
+
* object by hand.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} uri - space AT-URI
|
|
16
|
+
* @param {Partial<Omit<import('@pdsjs/core/ports').SpaceRow, 'uri'|'spaceDid'|'spaceType'>>} [fields]
|
|
17
|
+
* @returns {import('@pdsjs/core/ports').SpaceRow}
|
|
18
|
+
*/
|
|
19
|
+
export function makeSpaceRow(uri, fields = {}) {
|
|
20
|
+
const { spaceDid, spaceType } = parseSpaceUri(uri);
|
|
21
|
+
return {
|
|
22
|
+
uri,
|
|
23
|
+
spaceDid,
|
|
24
|
+
spaceType,
|
|
25
|
+
isOwner: fields.isOwner ?? false,
|
|
26
|
+
policy: fields.policy ?? 'member-list',
|
|
27
|
+
managingApp: fields.managingApp ?? null,
|
|
28
|
+
appAccessType: fields.appAccessType ?? 'open',
|
|
29
|
+
appAllowed: fields.appAllowed ? [...fields.appAllowed] : [],
|
|
30
|
+
createdAt: fields.createdAt ?? new Date().toISOString(),
|
|
31
|
+
deletedAt: fields.deletedAt ?? null,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/token.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export declare const SPACE_TOKEN_TYPES: {
|
|
2
|
+
delegation: {
|
|
3
|
+
typ: string;
|
|
4
|
+
kid: string;
|
|
5
|
+
expiresInSec: number;
|
|
6
|
+
requireAud: boolean;
|
|
7
|
+
};
|
|
8
|
+
credential: {
|
|
9
|
+
typ: string;
|
|
10
|
+
kid: string;
|
|
11
|
+
expiresInSec: number;
|
|
12
|
+
requireAud: boolean;
|
|
13
|
+
};
|
|
14
|
+
clientAttestation: {
|
|
15
|
+
typ: string;
|
|
16
|
+
kid: undefined;
|
|
17
|
+
expiresInSec: number;
|
|
18
|
+
requireAud: boolean;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export declare class SpaceTokenError extends Error {
|
|
22
|
+
code: string;
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} message
|
|
25
|
+
* @param {string} [code] - machine-readable error name for the XRPC response
|
|
26
|
+
*/
|
|
27
|
+
constructor(message: string, code?: string);
|
|
28
|
+
}
|
|
29
|
+
export type SpaceTokenPayload = {
|
|
30
|
+
iss: string;
|
|
31
|
+
/**
|
|
32
|
+
* - the space URI, or the client_id for an attestation
|
|
33
|
+
*/
|
|
34
|
+
sub: string;
|
|
35
|
+
aud?: string;
|
|
36
|
+
iat: number;
|
|
37
|
+
exp: number;
|
|
38
|
+
jti: string;
|
|
39
|
+
};
|
|
40
|
+
export type SpaceTokenHeader = {
|
|
41
|
+
alg: string;
|
|
42
|
+
typ: string;
|
|
43
|
+
kid?: string;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
47
|
+
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
48
|
+
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
|
|
49
|
+
* @returns {Promise<string>}
|
|
50
|
+
*/
|
|
51
|
+
export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, opts: {
|
|
52
|
+
iss: string;
|
|
53
|
+
sub: string;
|
|
54
|
+
aud?: string;
|
|
55
|
+
expiresInSec?: number;
|
|
56
|
+
kid?: string;
|
|
57
|
+
alg?: string;
|
|
58
|
+
}, signer: {
|
|
59
|
+
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
60
|
+
}): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Structural validation only, no signature check. This is as far as we go for
|
|
63
|
+
* client attestations, whose key comes from the client's JWKS rather than a DID
|
|
64
|
+
* document.
|
|
65
|
+
*
|
|
66
|
+
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
67
|
+
* @param {string} jwt
|
|
68
|
+
* @returns {{header: SpaceTokenHeader, payload: SpaceTokenPayload, signingInput: Uint8Array, sig: Uint8Array}}
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jwt: string): {
|
|
71
|
+
header: SpaceTokenHeader;
|
|
72
|
+
payload: SpaceTokenPayload;
|
|
73
|
+
signingInput: Uint8Array;
|
|
74
|
+
sig: Uint8Array;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
78
|
+
* @param {string} jwt
|
|
79
|
+
* @param {Object} opts
|
|
80
|
+
* @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
|
|
81
|
+
* - resolves the issuer to a did:key. Given `kid` so it can honour the key id.
|
|
82
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
83
|
+
* @param {string} [opts.aud] - required audience, when the caller knows it
|
|
84
|
+
* @param {string} [opts.sub] - required subject, when the caller knows it
|
|
85
|
+
* @returns {Promise<{header: SpaceTokenHeader, payload: SpaceTokenPayload}>}
|
|
86
|
+
*/
|
|
87
|
+
export declare function verifySpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jwt: string, opts: {
|
|
88
|
+
getSigningKey: (iss: string, kid?: string) => Promise<string>;
|
|
89
|
+
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
90
|
+
aud?: string;
|
|
91
|
+
sub?: string;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
header: SpaceTokenHeader;
|
|
94
|
+
payload: SpaceTokenPayload;
|
|
95
|
+
}>;
|
package/src/token.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// @pdsjs/spaces/token - the three space JWTs.
|
|
2
|
+
//
|
|
3
|
+
// A delegation token proves the user delegated to an app; a client attestation
|
|
4
|
+
// proves which app is acting; a space credential is what the authority issues in
|
|
5
|
+
// exchange, and is what repo hosts actually accept.
|
|
6
|
+
//
|
|
7
|
+
// They share a wire shape and differ only in who signs, who they address, and
|
|
8
|
+
// how long they live — so they are data, not three implementations.
|
|
9
|
+
|
|
10
|
+
import { base64UrlDecode, base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
|
|
11
|
+
|
|
12
|
+
export const SPACE_TOKEN_TYPES = {
|
|
13
|
+
delegation: {
|
|
14
|
+
typ: 'atproto-space-delegation+jwt',
|
|
15
|
+
kid: '#atproto',
|
|
16
|
+
expiresInSec: 60,
|
|
17
|
+
requireAud: true,
|
|
18
|
+
},
|
|
19
|
+
credential: {
|
|
20
|
+
typ: 'atproto-space-credential+jwt',
|
|
21
|
+
kid: '#atproto_space',
|
|
22
|
+
// Multi-use across repo hosts until it expires, so it carries no aud.
|
|
23
|
+
expiresInSec: 7200,
|
|
24
|
+
requireAud: false,
|
|
25
|
+
},
|
|
26
|
+
clientAttestation: {
|
|
27
|
+
typ: 'atproto-client-attestation+jwt',
|
|
28
|
+
kid: undefined,
|
|
29
|
+
expiresInSec: 60,
|
|
30
|
+
requireAud: true,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const CLOCK_SKEW_SEC = 5;
|
|
35
|
+
|
|
36
|
+
export class SpaceTokenError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} message
|
|
39
|
+
* @param {string} [code] - machine-readable error name for the XRPC response
|
|
40
|
+
*/
|
|
41
|
+
constructor(message, code = 'InvalidToken') {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'SpaceTokenError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} SpaceTokenPayload
|
|
50
|
+
* @property {string} iss
|
|
51
|
+
* @property {string} sub - the space URI, or the client_id for an attestation
|
|
52
|
+
* @property {string} [aud]
|
|
53
|
+
* @property {number} iat
|
|
54
|
+
* @property {number} exp
|
|
55
|
+
* @property {string} jti
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} SpaceTokenHeader
|
|
60
|
+
* @property {string} alg
|
|
61
|
+
* @property {string} typ
|
|
62
|
+
* @property {string} [kid]
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {Record<string, unknown>} json
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function jsonToB64Url(json) {
|
|
70
|
+
return base64UrlEncode(new TextEncoder().encode(JSON.stringify(json)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} b64
|
|
75
|
+
* @param {string} part
|
|
76
|
+
* @returns {any}
|
|
77
|
+
*/
|
|
78
|
+
function decodeJsonPart(b64, part) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(new TextDecoder().decode(base64UrlDecode(b64)));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw new SpaceTokenError(
|
|
83
|
+
`could not parse token ${part}: ${err instanceof Error ? err.message : String(err)}`,
|
|
84
|
+
'BadJwt',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
91
|
+
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
92
|
+
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
|
|
93
|
+
* @returns {Promise<string>}
|
|
94
|
+
*/
|
|
95
|
+
export async function createSpaceToken(type, opts, signer) {
|
|
96
|
+
const spec = SPACE_TOKEN_TYPES[type];
|
|
97
|
+
if (spec.requireAud && !opts.aud) {
|
|
98
|
+
throw new SpaceTokenError(`a ${type} token requires an "aud"`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
102
|
+
/** @type {SpaceTokenHeader} */
|
|
103
|
+
const header = { alg: opts.alg ?? 'ES256', typ: spec.typ };
|
|
104
|
+
const kid = opts.kid ?? spec.kid;
|
|
105
|
+
if (kid) header.kid = kid;
|
|
106
|
+
|
|
107
|
+
/** @type {SpaceTokenPayload} */
|
|
108
|
+
const payload = {
|
|
109
|
+
iss: opts.iss,
|
|
110
|
+
sub: opts.sub,
|
|
111
|
+
...(opts.aud ? { aud: opts.aud } : undefined),
|
|
112
|
+
iat,
|
|
113
|
+
exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
|
|
114
|
+
jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const signingInput = `${jsonToB64Url(header)}.${jsonToB64Url(payload)}`;
|
|
118
|
+
const sig = await signer.sign(new TextEncoder().encode(signingInput));
|
|
119
|
+
return `${signingInput}.${base64UrlEncode(sig)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Structural validation only, no signature check. This is as far as we go for
|
|
124
|
+
* client attestations, whose key comes from the client's JWKS rather than a DID
|
|
125
|
+
* document.
|
|
126
|
+
*
|
|
127
|
+
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
128
|
+
* @param {string} jwt
|
|
129
|
+
* @returns {{header: SpaceTokenHeader, payload: SpaceTokenPayload, signingInput: Uint8Array, sig: Uint8Array}}
|
|
130
|
+
*/
|
|
131
|
+
export function parseSpaceToken(type, jwt) {
|
|
132
|
+
const spec = SPACE_TOKEN_TYPES[type];
|
|
133
|
+
|
|
134
|
+
const parts = typeof jwt === 'string' ? jwt.split('.') : [];
|
|
135
|
+
if (parts.length !== 3) {
|
|
136
|
+
throw new SpaceTokenError('malformed token: expected 3 parts', 'BadJwt');
|
|
137
|
+
}
|
|
138
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
139
|
+
|
|
140
|
+
const header = decodeJsonPart(headerB64, 'header');
|
|
141
|
+
const payload = decodeJsonPart(payloadB64, 'payload');
|
|
142
|
+
|
|
143
|
+
if (header.typ !== spec.typ) {
|
|
144
|
+
throw new SpaceTokenError(
|
|
145
|
+
`wrong token type: expected "${spec.typ}", got "${header.typ}"`,
|
|
146
|
+
'BadJwtType',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (typeof header.alg !== 'string' || !header.alg) {
|
|
150
|
+
throw new SpaceTokenError('missing token "alg"', 'BadJwt');
|
|
151
|
+
}
|
|
152
|
+
if (!payload.iss)
|
|
153
|
+
throw new SpaceTokenError('missing token "iss"', 'BadJwtIss');
|
|
154
|
+
if (!payload.sub)
|
|
155
|
+
throw new SpaceTokenError('missing token "sub"', 'BadJwtSub');
|
|
156
|
+
if (typeof payload.exp !== 'number') {
|
|
157
|
+
throw new SpaceTokenError('missing token "exp"', 'BadJwt');
|
|
158
|
+
}
|
|
159
|
+
if (spec.requireAud && !payload.aud) {
|
|
160
|
+
throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience');
|
|
161
|
+
}
|
|
162
|
+
if (type === 'clientAttestation' && payload.iss !== payload.sub) {
|
|
163
|
+
throw new SpaceTokenError(
|
|
164
|
+
'client attestation "iss" and "sub" must both be the client_id',
|
|
165
|
+
'BadJwtIss',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
header,
|
|
171
|
+
payload,
|
|
172
|
+
signingInput: new TextEncoder().encode(`${headerB64}.${payloadB64}`),
|
|
173
|
+
sig: base64UrlDecode(sigB64),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
179
|
+
* @param {string} jwt
|
|
180
|
+
* @param {Object} opts
|
|
181
|
+
* @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
|
|
182
|
+
* - resolves the issuer to a did:key. Given `kid` so it can honour the key id.
|
|
183
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
184
|
+
* @param {string} [opts.aud] - required audience, when the caller knows it
|
|
185
|
+
* @param {string} [opts.sub] - required subject, when the caller knows it
|
|
186
|
+
* @returns {Promise<{header: SpaceTokenHeader, payload: SpaceTokenPayload}>}
|
|
187
|
+
*/
|
|
188
|
+
export async function verifySpaceToken(type, jwt, opts) {
|
|
189
|
+
const { header, payload, signingInput, sig } = parseSpaceToken(type, jwt);
|
|
190
|
+
|
|
191
|
+
const now = Math.floor(Date.now() / 1000);
|
|
192
|
+
if (now - CLOCK_SKEW_SEC >= payload.exp) {
|
|
193
|
+
throw new SpaceTokenError('token expired', 'JwtExpired');
|
|
194
|
+
}
|
|
195
|
+
if (opts.aud !== undefined && payload.aud !== opts.aud) {
|
|
196
|
+
throw new SpaceTokenError(
|
|
197
|
+
'token audience does not match this service',
|
|
198
|
+
'BadJwtAudience',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (opts.sub !== undefined && payload.sub !== opts.sub) {
|
|
202
|
+
throw new SpaceTokenError(
|
|
203
|
+
'token subject does not match the requested space',
|
|
204
|
+
'BadJwtSub',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const didKey = await opts.getSigningKey(payload.iss, header.kid);
|
|
209
|
+
let valid;
|
|
210
|
+
try {
|
|
211
|
+
valid = await opts.verifier.verify(didKey, signingInput, sig);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
throw new SpaceTokenError(
|
|
214
|
+
`could not verify token signature: ${err instanceof Error ? err.message : String(err)}`,
|
|
215
|
+
'BadJwtSignature',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!valid) {
|
|
219
|
+
throw new SpaceTokenError('invalid token signature', 'BadJwtSignature');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { header, payload };
|
|
223
|
+
}
|
package/src/uri.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const SPACE_MARKER = "space";
|
|
2
|
+
/**
|
|
3
|
+
* @param {{spaceDid: string, spaceType: string, skey: string,
|
|
4
|
+
* authorDid?: string|null, collection?: string|null, rkey?: string|null}} parts
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatSpaceUri(parts: {
|
|
8
|
+
spaceDid: string;
|
|
9
|
+
spaceType: string;
|
|
10
|
+
skey: string;
|
|
11
|
+
authorDid?: string | null;
|
|
12
|
+
collection?: string | null;
|
|
13
|
+
rkey?: string | null;
|
|
14
|
+
}): string;
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} uri
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export declare function isSpaceUri(uri: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} uri
|
|
22
|
+
* @returns {{spaceDid: string, spaceType: string, skey: string,
|
|
23
|
+
* authorDid: string|null, collection: string|null, rkey: string|null}}
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseSpaceUri(uri: string): {
|
|
26
|
+
spaceDid: string;
|
|
27
|
+
spaceType: string;
|
|
28
|
+
skey: string;
|
|
29
|
+
authorDid: string | null;
|
|
30
|
+
collection: string | null;
|
|
31
|
+
rkey: string | null;
|
|
32
|
+
};
|
package/src/uri.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @pdsjs/spaces/uri - permissioned space AT-URI addressing.
|
|
2
|
+
//
|
|
3
|
+
// at://{spaceDid}/space/{spaceType}/{skey}[/{authorDid}/{collection}/{rkey}]
|
|
4
|
+
//
|
|
5
|
+
// Space URIs are distinguished from public ones by the literal `space` segment,
|
|
6
|
+
// which contains no dots, whereas a collection NSID always contains at least two.
|
|
7
|
+
|
|
8
|
+
export const SPACE_MARKER = 'space';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{spaceDid: string, spaceType: string, skey: string,
|
|
12
|
+
* authorDid?: string|null, collection?: string|null, rkey?: string|null}} parts
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function formatSpaceUri(parts) {
|
|
16
|
+
const { spaceDid, spaceType, skey, authorDid, collection, rkey } = parts;
|
|
17
|
+
const optionalCount = [authorDid, collection, rkey].filter(Boolean).length;
|
|
18
|
+
if (optionalCount !== 0 && optionalCount !== 3) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'authorDid, collection, and rkey must be all present or all absent',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
let uri = `at://${spaceDid}/${SPACE_MARKER}/${spaceType}/${skey}`;
|
|
24
|
+
if (authorDid) uri += `/${authorDid}`;
|
|
25
|
+
if (collection) uri += `/${collection}`;
|
|
26
|
+
if (rkey) uri += `/${rkey}`;
|
|
27
|
+
return uri;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} uri
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isSpaceUri(uri) {
|
|
35
|
+
const segments = splitUri(uri);
|
|
36
|
+
return segments !== null && segments[1] === SPACE_MARKER;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} uri
|
|
41
|
+
* @returns {{spaceDid: string, spaceType: string, skey: string,
|
|
42
|
+
* authorDid: string|null, collection: string|null, rkey: string|null}}
|
|
43
|
+
*/
|
|
44
|
+
export function parseSpaceUri(uri) {
|
|
45
|
+
const segments = splitUri(uri);
|
|
46
|
+
if (segments === null || segments[1] !== SPACE_MARKER) {
|
|
47
|
+
throw new Error(`not a space URI: ${uri}`);
|
|
48
|
+
}
|
|
49
|
+
const [spaceDid, , spaceType, skey, authorDid, collection, rkey] = segments;
|
|
50
|
+
if (segments.length !== 4 && segments.length !== 7) {
|
|
51
|
+
throw new Error(`invalid space URI: ${uri}`);
|
|
52
|
+
}
|
|
53
|
+
if (!spaceDid.startsWith('did:')) {
|
|
54
|
+
throw new Error(`space authority must be a DID: ${uri}`);
|
|
55
|
+
}
|
|
56
|
+
if (!spaceType.includes('.')) {
|
|
57
|
+
throw new Error(`space type must be an NSID: ${uri}`);
|
|
58
|
+
}
|
|
59
|
+
if (!skey) throw new Error(`invalid space URI: ${uri}`);
|
|
60
|
+
if (segments.length === 4) {
|
|
61
|
+
return {
|
|
62
|
+
spaceDid,
|
|
63
|
+
spaceType,
|
|
64
|
+
skey,
|
|
65
|
+
authorDid: null,
|
|
66
|
+
collection: null,
|
|
67
|
+
rkey: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (!authorDid.startsWith('did:')) {
|
|
71
|
+
throw new Error(`space record author must be a DID: ${uri}`);
|
|
72
|
+
}
|
|
73
|
+
if (!collection.includes('.')) {
|
|
74
|
+
throw new Error(`collection must be an NSID: ${uri}`);
|
|
75
|
+
}
|
|
76
|
+
if (!rkey) throw new Error(`invalid space URI: ${uri}`);
|
|
77
|
+
return { spaceDid, spaceType, skey, authorDid, collection, rkey };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} uri
|
|
82
|
+
* @returns {string[]|null} host followed by path segments, or null if not an at:// URI
|
|
83
|
+
*/
|
|
84
|
+
function splitUri(uri) {
|
|
85
|
+
if (!uri.startsWith('at://')) return null;
|
|
86
|
+
const segments = uri.slice('at://'.length).split('/');
|
|
87
|
+
if (segments.length < 2 || segments.some((s) => s === '')) return null;
|
|
88
|
+
return segments;
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} didKey - did:key:z… (the did: prefix is optional)
|
|
3
|
+
* @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseDidKey(didKey: string): {
|
|
6
|
+
curve: 'p256' | 'secp256k1';
|
|
7
|
+
publicKey: Uint8Array;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
|
|
11
|
+
* @returns {import('@pdsjs/core/ports').SignatureVerifierPort}
|
|
12
|
+
*/
|
|
13
|
+
export declare function createVerifier(opts?: {
|
|
14
|
+
secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>;
|
|
15
|
+
}): import('@pdsjs/core/ports').SignatureVerifierPort;
|
package/src/verifier.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @pdsjs/spaces/verifier - signature verification port.
|
|
2
|
+
//
|
|
3
|
+
// P-256 ships built in on crypto.subtle. secp256k1 is injected: hand-rolling
|
|
4
|
+
// ECDSA verification is a different risk class from hand-rolling a hash, since
|
|
5
|
+
// a subtle bug means accepting a forged credential rather than a failing test.
|
|
6
|
+
// Without an injected verifier, spaces interoperate only with P-256 accounts.
|
|
7
|
+
|
|
8
|
+
import { base58btcDecode, P256_MULTICODEC } from '@pdsjs/core/plc';
|
|
9
|
+
|
|
10
|
+
const SECP256K1_MULTICODEC = [0xe7, 0x01];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} didKey - did:key:z… (the did: prefix is optional)
|
|
14
|
+
* @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
|
|
15
|
+
*/
|
|
16
|
+
export function parseDidKey(didKey) {
|
|
17
|
+
const value = didKey.startsWith('did:key:') ? didKey.slice(8) : didKey;
|
|
18
|
+
if (!value.startsWith('z')) {
|
|
19
|
+
throw new Error(`expected a did:key multibase value, got: ${didKey}`);
|
|
20
|
+
}
|
|
21
|
+
const bytes = base58btcDecode(value.slice(1));
|
|
22
|
+
if (bytes[0] === P256_MULTICODEC[0] && bytes[1] === P256_MULTICODEC[1]) {
|
|
23
|
+
return { curve: 'p256', publicKey: bytes.slice(2) };
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
bytes[0] === SECP256K1_MULTICODEC[0] &&
|
|
27
|
+
bytes[1] === SECP256K1_MULTICODEC[1]
|
|
28
|
+
) {
|
|
29
|
+
return { curve: 'secp256k1', publicKey: bytes.slice(2) };
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`unsupported did:key multicodec: ${didKey}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
|
|
36
|
+
* @returns {import('@pdsjs/core/ports').SignatureVerifierPort}
|
|
37
|
+
*/
|
|
38
|
+
export function createVerifier(opts = {}) {
|
|
39
|
+
return {
|
|
40
|
+
async verify(didKey, data, sig) {
|
|
41
|
+
const { curve, publicKey } = parseDidKey(didKey);
|
|
42
|
+
if (curve === 'p256') return verifyP256(publicKey, data, sig);
|
|
43
|
+
if (!opts.secp256k1) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'secp256k1 verification requires an injected verifier; ' +
|
|
46
|
+
'this deployment supports P-256 keys only',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return opts.secp256k1(publicKey, data, sig);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Uint8Array} publicKey - 33-byte compressed point
|
|
56
|
+
* @param {Uint8Array} data
|
|
57
|
+
* @param {Uint8Array} sig - 64-byte raw r||s
|
|
58
|
+
* @returns {Promise<boolean>}
|
|
59
|
+
*/
|
|
60
|
+
async function verifyP256(publicKey, data, sig) {
|
|
61
|
+
const key = await crypto.subtle.importKey(
|
|
62
|
+
'raw',
|
|
63
|
+
/** @type {BufferSource} */ (publicKey),
|
|
64
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
65
|
+
false,
|
|
66
|
+
['verify'],
|
|
67
|
+
);
|
|
68
|
+
return crypto.subtle.verify(
|
|
69
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
70
|
+
key,
|
|
71
|
+
/** @type {BufferSource} */ (sig),
|
|
72
|
+
/** @type {BufferSource} */ (data),
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/writer.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export declare const MAX_WRITES_PER_COMMIT = 200;
|
|
2
|
+
export declare class SpaceWriteError extends Error {
|
|
3
|
+
code: string;
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} message
|
|
6
|
+
* @param {string} code - machine-readable error name for the XRPC response
|
|
7
|
+
*/
|
|
8
|
+
constructor(message: string, code: string);
|
|
9
|
+
}
|
|
10
|
+
export declare class SpaceRecordNotFoundError extends SpaceWriteError {
|
|
11
|
+
/** @param {string} collection @param {string} rkey */
|
|
12
|
+
constructor(collection: string, rkey: string);
|
|
13
|
+
}
|
|
14
|
+
export declare class SpaceRecordAlreadyExistsError extends SpaceWriteError {
|
|
15
|
+
/** @param {string} collection @param {string} rkey */
|
|
16
|
+
constructor(collection: string, rkey: string);
|
|
17
|
+
}
|
|
18
|
+
export type SpaceWriteInput = {
|
|
19
|
+
action: 'create' | 'update' | 'put' | 'delete';
|
|
20
|
+
collection: string;
|
|
21
|
+
rkey: string;
|
|
22
|
+
/**
|
|
23
|
+
* - required for everything but delete
|
|
24
|
+
*/
|
|
25
|
+
record?: Object;
|
|
26
|
+
};
|
|
27
|
+
export type SpaceWriteResult = {
|
|
28
|
+
action: 'create' | 'update' | 'delete';
|
|
29
|
+
collection: string;
|
|
30
|
+
rkey: string;
|
|
31
|
+
/**
|
|
32
|
+
* - null for deletes
|
|
33
|
+
*/
|
|
34
|
+
cid: string | null;
|
|
35
|
+
/**
|
|
36
|
+
* - null for creates
|
|
37
|
+
*/
|
|
38
|
+
prev: string | null;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} SpaceWriteInput
|
|
42
|
+
* @property {'create'|'update'|'put'|'delete'} action
|
|
43
|
+
* @property {string} collection
|
|
44
|
+
* @property {string} rkey
|
|
45
|
+
* @property {Object} [record] - required for everything but delete
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} SpaceWriteResult
|
|
49
|
+
* @property {'create'|'update'|'delete'} action
|
|
50
|
+
* @property {string} collection
|
|
51
|
+
* @property {string} rkey
|
|
52
|
+
* @property {string|null} cid - null for deletes
|
|
53
|
+
* @property {string|null} prev - null for creates
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Apply a batch of writes to a permissioned repo as one commit.
|
|
57
|
+
*
|
|
58
|
+
* Writes resolve in order against the state the batch has built up, not just
|
|
59
|
+
* what was stored on entry, so a batch can create then update a record and a
|
|
60
|
+
* repeated create is still caught rather than double-counted in the set hash.
|
|
61
|
+
*
|
|
62
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} storage
|
|
63
|
+
* @param {Object} opts
|
|
64
|
+
* @param {string} opts.space - space AT-URI
|
|
65
|
+
* @param {SpaceWriteInput[]} opts.writes
|
|
66
|
+
* @param {string} [opts.now] - ISO timestamp, for deterministic tests
|
|
67
|
+
* @returns {Promise<{rev: string, setHash: Uint8Array, results: SpaceWriteResult[]}>}
|
|
68
|
+
*/
|
|
69
|
+
export declare function applyWrites(storage: import('@pdsjs/core/ports').SpaceStoragePort, { space, writes, now }: {
|
|
70
|
+
space: string;
|
|
71
|
+
writes: SpaceWriteInput[];
|
|
72
|
+
now?: string;
|
|
73
|
+
}): Promise<{
|
|
74
|
+
rev: string;
|
|
75
|
+
setHash: Uint8Array;
|
|
76
|
+
results: SpaceWriteResult[];
|
|
77
|
+
}>;
|