@hatk/hatk 0.0.1-alpha.85 → 0.0.1-alpha.87

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.
Files changed (89) hide show
  1. package/dist/adapter.d.ts.map +1 -1
  2. package/dist/adapter.js +2 -0
  3. package/dist/cli.js +5 -1
  4. package/dist/config.d.ts +47 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +13 -0
  7. package/dist/database/db.d.ts +11 -0
  8. package/dist/database/db.d.ts.map +1 -1
  9. package/dist/database/db.js +157 -22
  10. package/dist/database/index.d.ts +1 -1
  11. package/dist/database/index.d.ts.map +1 -1
  12. package/dist/database/index.js +1 -1
  13. package/dist/database/schema.d.ts +2 -0
  14. package/dist/database/schema.d.ts.map +1 -1
  15. package/dist/database/schema.js +40 -3
  16. package/dist/dev-entry.d.ts.map +1 -1
  17. package/dist/dev-entry.js +2 -2
  18. package/dist/feeds.d.ts +14 -0
  19. package/dist/feeds.d.ts.map +1 -1
  20. package/dist/feeds.js +24 -13
  21. package/dist/hooks.d.ts +9 -0
  22. package/dist/hooks.d.ts.map +1 -1
  23. package/dist/hooks.js +2 -2
  24. package/dist/hydrate.d.ts +42 -0
  25. package/dist/hydrate.d.ts.map +1 -1
  26. package/dist/hydrate.js +18 -5
  27. package/dist/indexer.d.ts +12 -0
  28. package/dist/indexer.d.ts.map +1 -1
  29. package/dist/indexer.js +20 -0
  30. package/dist/lex-types.d.ts +2 -0
  31. package/dist/lex-types.d.ts.map +1 -1
  32. package/dist/main.js +26 -2
  33. package/dist/oauth/db.d.ts +9 -0
  34. package/dist/oauth/db.d.ts.map +1 -1
  35. package/dist/oauth/db.js +12 -0
  36. package/dist/private-collections.d.ts +7 -2
  37. package/dist/private-collections.d.ts.map +1 -1
  38. package/dist/private-collections.js +9 -4
  39. package/dist/server.d.ts +8 -0
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +99 -10
  42. package/dist/spaces/blob.d.ts +51 -0
  43. package/dist/spaces/blob.d.ts.map +1 -0
  44. package/dist/spaces/blob.js +111 -0
  45. package/dist/spaces/credential.d.ts +62 -0
  46. package/dist/spaces/credential.d.ts.map +1 -0
  47. package/dist/spaces/credential.js +184 -0
  48. package/dist/spaces/engine.d.ts +97 -0
  49. package/dist/spaces/engine.d.ts.map +1 -0
  50. package/dist/spaces/engine.js +557 -0
  51. package/dist/spaces/guard.d.ts +41 -0
  52. package/dist/spaces/guard.d.ts.map +1 -0
  53. package/dist/spaces/guard.js +79 -0
  54. package/dist/spaces/identity.d.ts +37 -0
  55. package/dist/spaces/identity.d.ts.map +1 -0
  56. package/dist/spaces/identity.js +88 -0
  57. package/dist/spaces/index.d.ts +55 -0
  58. package/dist/spaces/index.d.ts.map +1 -0
  59. package/dist/spaces/index.js +133 -0
  60. package/dist/spaces/notify.d.ts +51 -0
  61. package/dist/spaces/notify.d.ts.map +1 -0
  62. package/dist/spaces/notify.js +121 -0
  63. package/dist/spaces/records.d.ts +37 -0
  64. package/dist/spaces/records.d.ts.map +1 -0
  65. package/dist/spaces/records.js +63 -0
  66. package/dist/spaces/store.d.ts +51 -0
  67. package/dist/spaces/store.d.ts.map +1 -0
  68. package/dist/spaces/store.js +99 -0
  69. package/dist/spaces/uri.d.ts +60 -0
  70. package/dist/spaces/uri.d.ts.map +1 -0
  71. package/dist/spaces/uri.js +97 -0
  72. package/dist/spaces/verify.d.ts +39 -0
  73. package/dist/spaces/verify.d.ts.map +1 -0
  74. package/dist/spaces/verify.js +87 -0
  75. package/dist/spaces/viewer.d.ts +51 -0
  76. package/dist/spaces/viewer.d.ts.map +1 -0
  77. package/dist/spaces/viewer.js +167 -0
  78. package/dist/spaces/visibility.d.ts +57 -0
  79. package/dist/spaces/visibility.d.ts.map +1 -0
  80. package/dist/spaces/visibility.js +81 -0
  81. package/dist/templates/feed.tpl +4 -0
  82. package/dist/templates/xrpc.tpl +13 -1
  83. package/dist/test.d.ts +2 -0
  84. package/dist/test.d.ts.map +1 -1
  85. package/dist/test.js +8 -5
  86. package/dist/xrpc.d.ts +19 -3
  87. package/dist/xrpc.d.ts.map +1 -1
  88. package/dist/xrpc.js +61 -31
  89. package/package.json +5 -3
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Sync state for permissioned spaces: which spaces this instance follows, and
3
+ * how far each writer's repo in them has been read.
4
+ *
5
+ * Deliberately separate from `_repos`, which is firehose and backfill
6
+ * bookkeeping keyed by DID alone. A writer's position is per space — the same
7
+ * account can hold a repo in several, at different revisions — so a DID is not
8
+ * a key here.
9
+ */
10
+ import { querySQL, runSQL } from "../database/db.js";
11
+ const toWatch = (row) => ({
12
+ space: row.space,
13
+ authority: row.authority,
14
+ spaceType: row.space_type,
15
+ readerDid: row.reader_did,
16
+ registeredUntil: row.registered_until,
17
+ lastError: row.last_error,
18
+ });
19
+ const WATCH_COLS = 'space, authority, space_type, reader_did, registered_until, last_error';
20
+ export async function listSpaceWatches() {
21
+ const rows = (await querySQL(`SELECT ${WATCH_COLS} FROM _space_watch ORDER BY space`));
22
+ return rows.map(toWatch);
23
+ }
24
+ export async function getSpaceWatch(space) {
25
+ const rows = (await querySQL(`SELECT ${WATCH_COLS} FROM _space_watch WHERE space = $1`, [space]));
26
+ return rows[0] ? toWatch(rows[0]) : null;
27
+ }
28
+ /**
29
+ * Start following a space, or refresh what is known about one already followed.
30
+ *
31
+ * Never clears `reader_did` or `registered_until`: a second call to watch an
32
+ * existing space is a re-assertion of interest, not a reset of its progress.
33
+ */
34
+ export async function putSpaceWatch(input) {
35
+ const existing = await getSpaceWatch(input.space);
36
+ if (existing) {
37
+ await runSQL(`UPDATE _space_watch SET authority = $1, space_type = $2, updated_at = $3 WHERE space = $4`, [
38
+ input.authority,
39
+ input.spaceType,
40
+ new Date().toISOString(),
41
+ input.space,
42
+ ]);
43
+ return;
44
+ }
45
+ await runSQL(`INSERT INTO _space_watch (space, authority, space_type, reader_did, registered_until, last_error, updated_at)
46
+ VALUES ($1, $2, $3, NULL, NULL, NULL, $4)`, [input.space, input.authority, input.spaceType, new Date().toISOString()]);
47
+ }
48
+ export async function updateSpaceWatch(space, patch) {
49
+ const sets = [];
50
+ const params = [];
51
+ let idx = 1;
52
+ if (patch.readerDid !== undefined) {
53
+ sets.push(`reader_did = $${idx++}`);
54
+ params.push(patch.readerDid);
55
+ }
56
+ if (patch.registeredUntil !== undefined) {
57
+ sets.push(`registered_until = $${idx++}`);
58
+ params.push(patch.registeredUntil);
59
+ }
60
+ if (patch.lastError !== undefined) {
61
+ sets.push(`last_error = $${idx++}`);
62
+ params.push(patch.lastError);
63
+ }
64
+ if (sets.length === 0)
65
+ return;
66
+ sets.push(`updated_at = $${idx++}`);
67
+ params.push(new Date().toISOString());
68
+ params.push(space);
69
+ await runSQL(`UPDATE _space_watch SET ${sets.join(', ')} WHERE space = $${idx}`, params);
70
+ }
71
+ export async function deleteSpaceWatch(space) {
72
+ await runSQL(`DELETE FROM _space_watch WHERE space = $1`, [space]);
73
+ await runSQL(`DELETE FROM _space_repos WHERE space = $1`, [space]);
74
+ }
75
+ export async function listSpaceRepos(space) {
76
+ const rows = (await querySQL(`SELECT space, did, pds, rev FROM _space_repos WHERE space = $1 ORDER BY did`, [
77
+ space,
78
+ ]));
79
+ return rows;
80
+ }
81
+ export async function getSpaceRepo(space, did) {
82
+ const rows = (await querySQL(`SELECT space, did, pds, rev FROM _space_repos WHERE space = $1 AND did = $2`, [
83
+ space,
84
+ did,
85
+ ]));
86
+ return rows[0] ?? null;
87
+ }
88
+ export async function putSpaceRepo(input) {
89
+ await runSQL(`INSERT OR REPLACE INTO _space_repos (space, did, pds, rev, synced_at) VALUES ($1, $2, $3, $4, $5)`, [
90
+ input.space,
91
+ input.did,
92
+ input.pds,
93
+ input.rev,
94
+ new Date().toISOString(),
95
+ ]);
96
+ }
97
+ export async function deleteSpaceRepo(space, did) {
98
+ await runSQL(`DELETE FROM _space_repos WHERE space = $1 AND did = $2`, [space, did]);
99
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * AT-URIs for permissioned spaces (proposal 0016).
3
+ *
4
+ * A space is named by a three-segment ref, and a record inside one carries the
5
+ * writer, collection and rkey after it:
6
+ *
7
+ * at://{authority}/space/{type}/{skey} — the space
8
+ * at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey} — a record in it
9
+ *
10
+ * Seven segments where an ordinary record URI has five, and `space` sitting
11
+ * where a collection would. Anything that reads a collection out of a URI by
12
+ * position has to know the difference or it reads back the literal 'space'.
13
+ *
14
+ * The authority is always a DID rather than a handle: a space's membership is
15
+ * keyed on DIDs, so a handle could not be resolved to a member.
16
+ */
17
+ export interface SpaceRef {
18
+ /** The DID the space is anchored on, and the only party that issues credentials for it. */
19
+ authority: string;
20
+ /** The space type NSID, whose lexicon declares which collections the space holds. */
21
+ type: string;
22
+ skey: string;
23
+ }
24
+ export interface SpaceRecordRef extends SpaceRef {
25
+ /** The three-segment ref of the space this record lives in. */
26
+ space: string;
27
+ /** The account whose repo holds the record. Records stay with their writer. */
28
+ writer: string;
29
+ collection: string;
30
+ rkey: string;
31
+ }
32
+ export declare function spaceRefUri(ref: SpaceRef): string;
33
+ /** Parse `at://{authority}/space/{type}/{skey}` — the space itself, not a record in it. */
34
+ export declare function parseSpaceRef(uri: string): SpaceRef | null;
35
+ /** Whether a URI names a space or anything inside one. */
36
+ export declare function isSpaceUri(uri: string): boolean;
37
+ /** `at://{authority}/space/{type}/{skey}` from its parts; `self` is the skey a singleton space uses. */
38
+ export declare function spaceUri(authority: string, type: string, skey?: string): string;
39
+ /** The record key: the last segment of either URI shape. */
40
+ export declare function rkeyOf(uri: string): string;
41
+ export declare function spaceRecordUri(space: string, writer: string, collection: string, rkey: string): string;
42
+ export declare function parseSpaceRecordUri(uri: string): SpaceRecordRef | null;
43
+ /** The space a record URI belongs to, or undefined for an ordinary repo URI. */
44
+ export declare function spaceFromUri(uri: string): string | undefined;
45
+ /**
46
+ * The collection a record URI names, whichever shape it is.
47
+ *
48
+ * The one place the two URI forms are reconciled; every positional read of a
49
+ * collection goes through here.
50
+ */
51
+ export declare function collectionFromRecordUri(uri: string): string | undefined;
52
+ /**
53
+ * The audience a delegation token for this space is addressed to.
54
+ *
55
+ * An authority that publishes no `#atproto_space_host` entry is still reached
56
+ * at its `#atproto_pds` endpoint — the fragment names the audience, not the
57
+ * address.
58
+ */
59
+ export declare function spaceHostAud(authority: string): string;
60
+ //# sourceMappingURL=uri.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uri.d.ts","sourceRoot":"","sources":["../../src/spaces/uri.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,MAAM,WAAW,QAAQ;IACvB,2FAA2F;IAC3F,SAAS,EAAE,MAAM,CAAA;IACjB,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ;IAC9C,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAA;IACb,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;CACb;AAOD,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAEjD;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAM1D;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAG/C;AAED,wGAAwG;AACxG,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,SAAS,GAAG,MAAM,CAE/E;AAED,4DAA4D;AAC5D,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE1C;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtG;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CActE;AAED,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE5D;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKvE;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEtD"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * AT-URIs for permissioned spaces (proposal 0016).
3
+ *
4
+ * A space is named by a three-segment ref, and a record inside one carries the
5
+ * writer, collection and rkey after it:
6
+ *
7
+ * at://{authority}/space/{type}/{skey} — the space
8
+ * at://{authority}/space/{type}/{skey}/{writer}/{collection}/{rkey} — a record in it
9
+ *
10
+ * Seven segments where an ordinary record URI has five, and `space` sitting
11
+ * where a collection would. Anything that reads a collection out of a URI by
12
+ * position has to know the difference or it reads back the literal 'space'.
13
+ *
14
+ * The authority is always a DID rather than a handle: a space's membership is
15
+ * keyed on DIDs, so a handle could not be resolved to a member.
16
+ */
17
+ const SPACE_MARKER = 'space';
18
+ const AT_PREFIX = 'at://';
19
+ function segments(uri) {
20
+ if (!uri.startsWith(AT_PREFIX))
21
+ return null;
22
+ return uri.slice(AT_PREFIX.length).split('/');
23
+ }
24
+ export function spaceRefUri(ref) {
25
+ return `${AT_PREFIX}${ref.authority}/${SPACE_MARKER}/${ref.type}/${ref.skey}`;
26
+ }
27
+ /** Parse `at://{authority}/space/{type}/{skey}` — the space itself, not a record in it. */
28
+ export function parseSpaceRef(uri) {
29
+ const parts = segments(uri);
30
+ if (!parts || parts.length !== 4)
31
+ return null;
32
+ const [authority, marker, type, skey] = parts;
33
+ if (marker !== SPACE_MARKER || !authority.startsWith('did:') || !type || !skey)
34
+ return null;
35
+ return { authority, type, skey };
36
+ }
37
+ /** Whether a URI names a space or anything inside one. */
38
+ export function isSpaceUri(uri) {
39
+ const parts = segments(uri);
40
+ return parts != null && parts.length >= 4 && parts[1] === SPACE_MARKER && parts[0].startsWith('did:');
41
+ }
42
+ /** `at://{authority}/space/{type}/{skey}` from its parts; `self` is the skey a singleton space uses. */
43
+ export function spaceUri(authority, type, skey = 'self') {
44
+ return `${AT_PREFIX}${authority}/${SPACE_MARKER}/${type}/${skey}`;
45
+ }
46
+ /** The record key: the last segment of either URI shape. */
47
+ export function rkeyOf(uri) {
48
+ return uri.slice(uri.lastIndexOf('/') + 1);
49
+ }
50
+ export function spaceRecordUri(space, writer, collection, rkey) {
51
+ return `${space}/${writer}/${collection}/${rkey}`;
52
+ }
53
+ export function parseSpaceRecordUri(uri) {
54
+ const parts = segments(uri);
55
+ if (!parts || parts.length !== 7)
56
+ return null;
57
+ const [authority, marker, type, skey, writer, collection, rkey] = parts;
58
+ if (marker !== SPACE_MARKER || !authority.startsWith('did:') || !collection || !rkey)
59
+ return null;
60
+ return {
61
+ authority,
62
+ type,
63
+ skey,
64
+ writer,
65
+ collection,
66
+ rkey,
67
+ space: `${AT_PREFIX}${authority}/${SPACE_MARKER}/${type}/${skey}`,
68
+ };
69
+ }
70
+ /** The space a record URI belongs to, or undefined for an ordinary repo URI. */
71
+ export function spaceFromUri(uri) {
72
+ return parseSpaceRecordUri(uri)?.space ?? undefined;
73
+ }
74
+ /**
75
+ * The collection a record URI names, whichever shape it is.
76
+ *
77
+ * The one place the two URI forms are reconciled; every positional read of a
78
+ * collection goes through here.
79
+ */
80
+ export function collectionFromRecordUri(uri) {
81
+ const parts = segments(uri);
82
+ if (!parts)
83
+ return undefined;
84
+ if (parts.length === 7 && parts[1] === SPACE_MARKER)
85
+ return parts[5];
86
+ return parts.length >= 3 ? parts[1] : undefined;
87
+ }
88
+ /**
89
+ * The audience a delegation token for this space is addressed to.
90
+ *
91
+ * An authority that publishes no `#atproto_space_host` entry is still reached
92
+ * at its `#atproto_pds` endpoint — the fragment names the audience, not the
93
+ * address.
94
+ */
95
+ export function spaceHostAud(authority) {
96
+ return `${authority}#atproto_space_host`;
97
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Verifying a signature made by somebody else's atproto signing key.
3
+ *
4
+ * Everything hatk signs, it signs with ES256, because it chose its own keys and
5
+ * WebCrypto implements that curve. Everything it has to *verify* here was
6
+ * signed by an account it does not control, and most `did:plc` accounts sign
7
+ * with secp256k1 — a curve WebCrypto does not implement at all. So the two
8
+ * curves atproto allows are handled explicitly, over @noble/curves.
9
+ *
10
+ * A key arrives as a multibase string in a DID document's verification method
11
+ * (`publicKeyMultibase` on a `Multikey`): base58btc, prefixed with a multicodec
12
+ * varint naming the curve, then the compressed point. The prefix is the only
13
+ * thing that says which curve it is, so it is read rather than guessed.
14
+ */
15
+ export type AtprotoCurve = 'secp256k1' | 'p256';
16
+ export declare class SignatureError extends Error {
17
+ }
18
+ /**
19
+ * Decode base58btc. Written out rather than pulled in: it is short, it is the
20
+ * only encoding needed, and a wrong answer here fails closed rather than
21
+ * silently accepting something.
22
+ */
23
+ export declare function decodeBase58btc(input: string): Uint8Array;
24
+ export interface PublicKey {
25
+ curve: AtprotoCurve;
26
+ /** The compressed point, 33 bytes. */
27
+ bytes: Uint8Array;
28
+ }
29
+ /** Read a `publicKeyMultibase` from a DID document's verification method. */
30
+ export declare function parseMultibaseKey(multibase: string): PublicKey;
31
+ /**
32
+ * Verify a raw `r || s` signature over `message`.
33
+ *
34
+ * Low-S is required — atproto mandates it and a malleable signature would let
35
+ * the same assertion be presented twice with different bytes — and is what
36
+ * @noble enforces by default.
37
+ */
38
+ export declare function verifySignature(key: PublicKey, signature: Uint8Array, message: Uint8Array): boolean;
39
+ //# sourceMappingURL=verify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../src/spaces/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAYH,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,MAAM,CAAA;AAE/C,qBAAa,cAAe,SAAQ,KAAK;CAAG;AAE5C;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAuBzD;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAA;IACnB,sCAAsC;IACtC,KAAK,EAAE,UAAU,CAAA;CAClB;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAO9D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAUnG"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Verifying a signature made by somebody else's atproto signing key.
3
+ *
4
+ * Everything hatk signs, it signs with ES256, because it chose its own keys and
5
+ * WebCrypto implements that curve. Everything it has to *verify* here was
6
+ * signed by an account it does not control, and most `did:plc` accounts sign
7
+ * with secp256k1 — a curve WebCrypto does not implement at all. So the two
8
+ * curves atproto allows are handled explicitly, over @noble/curves.
9
+ *
10
+ * A key arrives as a multibase string in a DID document's verification method
11
+ * (`publicKeyMultibase` on a `Multikey`): base58btc, prefixed with a multicodec
12
+ * varint naming the curve, then the compressed point. The prefix is the only
13
+ * thing that says which curve it is, so it is read rather than guessed.
14
+ */
15
+ import { p256 } from '@noble/curves/nist.js';
16
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
17
+ import { sha256 } from '@noble/hashes/sha2.js';
18
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
19
+ /** Multicodec varints for the two curves atproto allows. */
20
+ const SECP256K1_PREFIX = [0xe7, 0x01];
21
+ const P256_PREFIX = [0x80, 0x24];
22
+ export class SignatureError extends Error {
23
+ }
24
+ /**
25
+ * Decode base58btc. Written out rather than pulled in: it is short, it is the
26
+ * only encoding needed, and a wrong answer here fails closed rather than
27
+ * silently accepting something.
28
+ */
29
+ export function decodeBase58btc(input) {
30
+ if (input.length === 0)
31
+ return new Uint8Array(0);
32
+ const bytes = [0];
33
+ for (const char of input) {
34
+ const value = BASE58_ALPHABET.indexOf(char);
35
+ if (value < 0)
36
+ throw new SignatureError(`Not base58btc: ${char}`);
37
+ let carry = value;
38
+ for (let i = 0; i < bytes.length; i++) {
39
+ carry += bytes[i] * 58;
40
+ bytes[i] = carry & 0xff;
41
+ carry >>= 8;
42
+ }
43
+ while (carry > 0) {
44
+ bytes.push(carry & 0xff);
45
+ carry >>= 8;
46
+ }
47
+ }
48
+ // Every leading '1' is a leading zero byte that the arithmetic above drops.
49
+ for (const char of input) {
50
+ if (char !== '1')
51
+ break;
52
+ bytes.push(0);
53
+ }
54
+ return new Uint8Array(bytes.reverse());
55
+ }
56
+ /** Read a `publicKeyMultibase` from a DID document's verification method. */
57
+ export function parseMultibaseKey(multibase) {
58
+ if (!multibase.startsWith('z'))
59
+ throw new SignatureError('Key is not base58btc multibase');
60
+ const decoded = decodeBase58btc(multibase.slice(1));
61
+ const starts = (prefix) => prefix.every((b, i) => decoded[i] === b);
62
+ if (starts(SECP256K1_PREFIX))
63
+ return { curve: 'secp256k1', bytes: decoded.slice(SECP256K1_PREFIX.length) };
64
+ if (starts(P256_PREFIX))
65
+ return { curve: 'p256', bytes: decoded.slice(P256_PREFIX.length) };
66
+ throw new SignatureError('Key is neither secp256k1 nor p256');
67
+ }
68
+ /**
69
+ * Verify a raw `r || s` signature over `message`.
70
+ *
71
+ * Low-S is required — atproto mandates it and a malleable signature would let
72
+ * the same assertion be presented twice with different bytes — and is what
73
+ * @noble enforces by default.
74
+ */
75
+ export function verifySignature(key, signature, message) {
76
+ if (signature.length !== 64)
77
+ return false;
78
+ const digest = sha256(message);
79
+ try {
80
+ const curve = key.curve === 'secp256k1' ? secp256k1 : p256;
81
+ return curve.verify(signature, digest, key.bytes);
82
+ }
83
+ catch {
84
+ // A malformed point or scalar is a failed verification, not a crash.
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Which spaces a given viewer may be shown.
3
+ *
4
+ * The question has exactly one authoritative answer, and hatk is not the one
5
+ * holding it: a space's authority decides who may read it, at the moment it
6
+ * mints a credential, from records and rules that live on the community's own
7
+ * host. Re-deriving that here — reading the access and membership records out
8
+ * of the index and evaluating them — would be a second implementation of
9
+ * somebody else's authorization, drifting from it in exactly the cases that
10
+ * matter. Ejected members keep their content readable in the alpha; an invites
11
+ * space is write-for-anyone and read-for-owner; a host may change its rules
12
+ * without telling us.
13
+ *
14
+ * So the check is the real thing: ask the authority to mint a credential as
15
+ * this viewer, and take the answer. It succeeds precisely when the viewer may
16
+ * read the space, because that is the same call their own browser would make.
17
+ *
18
+ * What that costs is a round trip per space, so the answer is cached — which
19
+ * means revocation lags by up to the cache's lifetime. Worth stating plainly:
20
+ * somebody ejected from a community may still be served that community's rows
21
+ * for a few minutes. The alternative is two round trips on every request, and
22
+ * the space host itself already lets a minted credential outlive a revocation
23
+ * by up to two hours.
24
+ */
25
+ import type { OAuthConfig } from '../config.ts';
26
+ import { type SpaceCredential } from './credential.ts';
27
+ /**
28
+ * The spaces this viewer may be shown, as the scope for a request.
29
+ *
30
+ * Empty for a signed-out visitor, always: obtaining a credential begins with a
31
+ * delegation token from the reader's own PDS, so there is no anonymous read
32
+ * path into a space at all. `readableBy: ["public"]` means any authenticated
33
+ * reader, never anyone.
34
+ */
35
+ export declare function readableSpacesFor(oauth: OAuthConfig | null, viewer: {
36
+ did: string;
37
+ } | null): Promise<string[]>;
38
+ /**
39
+ * This viewer's own credential for a space, or null if they may not read it.
40
+ *
41
+ * For the reads that cannot go through the index — a blob, which has no public
42
+ * URL by design and must be fetched from the repo that holds it with the same
43
+ * credential as the record naming it.
44
+ */
45
+ export declare function viewerCredential(oauth: OAuthConfig | null, viewer: {
46
+ did: string;
47
+ } | null, space: string): Promise<SpaceCredential | null>;
48
+ /** Drop a viewer's cached answer — after they sign out, or to stop honouring it early. */
49
+ export declare function forgetViewerSpaces(viewerDid: string): void;
50
+ export declare function resetViewerSpaces(): void;
51
+ //# sourceMappingURL=viewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer.d.ts","sourceRoot":"","sources":["../../src/spaces/viewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C,OAAO,EAAoC,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAsGxF;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,EAAE,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAIpH;AAED;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,WAAW,GAAG,IAAI,EACzB,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC9B,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAkBjC;AAED,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAE1D;AAED,wBAAgB,iBAAiB,IAAI,IAAI,CAGxC"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Which spaces a given viewer may be shown.
3
+ *
4
+ * The question has exactly one authoritative answer, and hatk is not the one
5
+ * holding it: a space's authority decides who may read it, at the moment it
6
+ * mints a credential, from records and rules that live on the community's own
7
+ * host. Re-deriving that here — reading the access and membership records out
8
+ * of the index and evaluating them — would be a second implementation of
9
+ * somebody else's authorization, drifting from it in exactly the cases that
10
+ * matter. Ejected members keep their content readable in the alpha; an invites
11
+ * space is write-for-anyone and read-for-owner; a host may change its rules
12
+ * without telling us.
13
+ *
14
+ * So the check is the real thing: ask the authority to mint a credential as
15
+ * this viewer, and take the answer. It succeeds precisely when the viewer may
16
+ * read the space, because that is the same call their own browser would make.
17
+ *
18
+ * What that costs is a round trip per space, so the answer is cached — which
19
+ * means revocation lags by up to the cache's lifetime. Worth stating plainly:
20
+ * somebody ejected from a community may still be served that community's rows
21
+ * for a few minutes. The alternative is two round trips on every request, and
22
+ * the space host itself already lets a minted credential outlive a revocation
23
+ * by up to two hours.
24
+ */
25
+ import { emit } from "../logger.js";
26
+ import { mintSpaceCredential, isSpaceGone } from "./credential.js";
27
+ import { listSpaceWatches } from "./store.js";
28
+ /** How long a viewer's readable set is trusted before it is checked again. */
29
+ const TTL_MS = 5 * 60 * 1000;
30
+ /** Enough for a community's worth of spaces without opening a socket per space. */
31
+ const CONCURRENCY = 6;
32
+ /**
33
+ * Viewers held at once. Past this the oldest entry goes — a cache that grows
34
+ * with every account that has ever signed in is a leak, and the cost of a miss
35
+ * is one round trip.
36
+ */
37
+ const MAX_VIEWERS = 1000;
38
+ const cache = new Map();
39
+ /**
40
+ * Resolutions in flight, one per viewer. A page fires several requests at
41
+ * once and every one of them misses the same cold cache; without this each
42
+ * would mint its own credential per space, and a member landing on the home
43
+ * page cost six times what it needed to.
44
+ */
45
+ const inFlight = new Map();
46
+ function entryFor(viewerDid) {
47
+ const entry = cache.get(viewerDid);
48
+ if (!entry)
49
+ return undefined;
50
+ if (entry.expiresAt <= Date.now()) {
51
+ cache.delete(viewerDid);
52
+ return undefined;
53
+ }
54
+ // Re-inserted so iteration order is least-recently-used first.
55
+ cache.delete(viewerDid);
56
+ cache.set(viewerDid, entry);
57
+ return entry;
58
+ }
59
+ function store(viewerDid, credentials) {
60
+ const entry = { expiresAt: Date.now() + TTL_MS, credentials };
61
+ cache.set(viewerDid, entry);
62
+ while (cache.size > MAX_VIEWERS) {
63
+ const oldest = cache.keys().next();
64
+ if (oldest.done)
65
+ break;
66
+ cache.delete(oldest.value);
67
+ }
68
+ return entry;
69
+ }
70
+ async function inBatches(items, size, fn) {
71
+ const out = [];
72
+ for (let i = 0; i < items.length; i += size) {
73
+ out.push(...(await Promise.all(items.slice(i, i + size).map(fn))));
74
+ }
75
+ return out;
76
+ }
77
+ function resolve(oauth, viewerDid) {
78
+ const pending = inFlight.get(viewerDid);
79
+ if (pending)
80
+ return pending;
81
+ const task = resolveNow(oauth, viewerDid).finally(() => {
82
+ if (inFlight.get(viewerDid) === task)
83
+ inFlight.delete(viewerDid);
84
+ });
85
+ inFlight.set(viewerDid, task);
86
+ return task;
87
+ }
88
+ async function resolveNow(oauth, viewerDid) {
89
+ const watches = await listSpaceWatches();
90
+ const credentials = new Map();
91
+ if (watches.length === 0)
92
+ return store(viewerDid, credentials);
93
+ const elapsed = Date.now();
94
+ await inBatches(watches, CONCURRENCY, async (watch) => {
95
+ try {
96
+ credentials.set(watch.space, await mintSpaceCredential(oauth, watch.space, viewerDid));
97
+ }
98
+ catch (err) {
99
+ // Every refusal is a no, and a refusal is the ordinary shape of "not a
100
+ // member" rather than a fault worth reporting. A space that has been
101
+ // deleted is a no for everybody and the sweep will stop following it.
102
+ credentials.set(watch.space, null);
103
+ if (isSpaceGone(err))
104
+ return;
105
+ }
106
+ });
107
+ const readable = [...credentials].filter(([, c]) => c !== null).length;
108
+ emit('spaces', 'viewer_resolved', {
109
+ viewer_did: viewerDid,
110
+ checked: watches.length,
111
+ readable,
112
+ duration_ms: Date.now() - elapsed,
113
+ });
114
+ return store(viewerDid, credentials);
115
+ }
116
+ /**
117
+ * The spaces this viewer may be shown, as the scope for a request.
118
+ *
119
+ * Empty for a signed-out visitor, always: obtaining a credential begins with a
120
+ * delegation token from the reader's own PDS, so there is no anonymous read
121
+ * path into a space at all. `readableBy: ["public"]` means any authenticated
122
+ * reader, never anyone.
123
+ */
124
+ export async function readableSpacesFor(oauth, viewer) {
125
+ if (!oauth || !viewer)
126
+ return [];
127
+ const entry = entryFor(viewer.did) ?? (await resolve(oauth, viewer.did));
128
+ return [...entry.credentials].filter(([, c]) => c !== null).map(([space]) => space);
129
+ }
130
+ /**
131
+ * This viewer's own credential for a space, or null if they may not read it.
132
+ *
133
+ * For the reads that cannot go through the index — a blob, which has no public
134
+ * URL by design and must be fetched from the repo that holds it with the same
135
+ * credential as the record naming it.
136
+ */
137
+ export async function viewerCredential(oauth, viewer, space) {
138
+ if (!oauth || !viewer)
139
+ return null;
140
+ const entry = entryFor(viewer.did) ?? (await resolve(oauth, viewer.did));
141
+ const cached = entry.credentials.get(space);
142
+ if (cached !== undefined) {
143
+ // A credential can expire inside the entry's own lifetime; the answer to
144
+ // "may they read it" outlives the token that proved it.
145
+ if (cached === null)
146
+ return null;
147
+ if (cached.expiresAt > Date.now())
148
+ return cached;
149
+ }
150
+ try {
151
+ const credential = await mintSpaceCredential(oauth, space, viewer.did);
152
+ entry.credentials.set(space, credential);
153
+ return credential;
154
+ }
155
+ catch {
156
+ entry.credentials.set(space, null);
157
+ return null;
158
+ }
159
+ }
160
+ /** Drop a viewer's cached answer — after they sign out, or to stop honouring it early. */
161
+ export function forgetViewerSpaces(viewerDid) {
162
+ cache.delete(viewerDid);
163
+ }
164
+ export function resetViewerSpaces() {
165
+ cache.clear();
166
+ inFlight.clear();
167
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which permissioned spaces the read in flight is allowed to serve.
3
+ *
4
+ * Space records are not public data. A space's authority decides who may read
5
+ * it, and it decides that one caller at a time — so a row indexed out of a
6
+ * space carries an audience, and serving it to a request that has not proven
7
+ * it belongs to that audience discloses private data no matter how the query
8
+ * was written.
9
+ *
10
+ * hatk has no other per-row visibility concept. The takedown join is
11
+ * viewer-independent and `privateCollections` is all-or-nothing, so neither
12
+ * could carry this. The rule here is instead:
13
+ *
14
+ * a row with `space IS NULL` is public repo data and always readable;
15
+ * a row with a space is readable only inside a scope that names that space.
16
+ *
17
+ * The default is an empty scope, which serves no space rows at all. That is
18
+ * deliberate: a read path nobody remembered to scope fails closed and shows a
19
+ * member nothing, rather than failing open and showing a stranger everything.
20
+ * Every query over a record table applies `spaceFilterSql`; the scope is
21
+ * established per request by whoever established the viewer.
22
+ */
23
+ /**
24
+ * Run `fn` with exactly these spaces readable.
25
+ *
26
+ * Replaces rather than extends any enclosing scope: widening what a nested read
27
+ * may see is never what a caller means, and a scope that only ever narrows is
28
+ * one that can be reasoned about locally.
29
+ */
30
+ export declare function withReadableSpaces<T>(spaces: Iterable<string>, fn: () => T): T;
31
+ /**
32
+ * Set the readable spaces for the rest of the current async context.
33
+ *
34
+ * `withReadableSpaces` wants a callback, and a request handler is a long body
35
+ * rather than something to wrap — restructuring it around a closure to hold a
36
+ * scope would be the tail wagging the dog. A server handler already runs in its
37
+ * own async context per request, which is the case `enterWith` exists for.
38
+ */
39
+ export declare function enterReadableSpaces(spaces: Iterable<string>): void;
40
+ /** The spaces this read may serve. Empty outside any scope. */
41
+ export declare function readableSpaces(): ReadonlySet<string>;
42
+ /** Whether a space would be served to the read in flight. */
43
+ export declare function isSpaceReadable(space: string | null | undefined): boolean;
44
+ /**
45
+ * The space gate as a SQL predicate, for a table aliased `alias` (pass '' for
46
+ * an unaliased table).
47
+ *
48
+ * `startIdx` is the next free `$N` placeholder; the returned `nextIdx` is the
49
+ * next one after this predicate's own. With nothing readable the predicate
50
+ * binds no parameters at all, which is the shape almost every request takes.
51
+ */
52
+ export declare function spaceFilterSql(alias: string, startIdx: number): {
53
+ sql: string;
54
+ params: string[];
55
+ nextIdx: number;
56
+ };
57
+ //# sourceMappingURL=visibility.d.ts.map