@agnt-sdk/simon-server 0.1.1 → 0.2.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/dist/account.d.ts +3 -1
- package/dist/account.js +7 -3
- package/dist/deliver.js +1 -1
- package/dist/extensions.d.ts +1 -1
- package/dist/notice.js +2 -2
- package/dist/parts.js +2 -2
- package/dist/public.js +0 -1
- package/dist/published.js +6 -11
- package/dist/readers.d.ts +14 -6
- package/dist/readers.js +17 -7
- package/dist/register.js +4 -5
- package/dist/resolver.d.ts +1 -2
- package/dist/send.d.ts +1 -1
- package/dist/send.js +55 -9
- package/dist/server.js +1 -1
- package/dist/store/contract.js +13 -1
- package/dist/store/sqlite-migration.js +3 -3
- package/dist/store/sqlite.d.ts +1 -0
- package/dist/store/sqlite.js +12 -3
- package/dist/store/store.d.ts +7 -2
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +4 -1
- package/package.json +3 -3
package/dist/account.d.ts
CHANGED
|
@@ -52,7 +52,9 @@ export declare function endGrant(ctx: Context, req: Request): Promise<Reply>;
|
|
|
52
52
|
export declare function cancelRecovery(ctx: Context, req: Request): Promise<Reply>;
|
|
53
53
|
/**
|
|
54
54
|
* A key principal ends its pairing: the address retires at once, its keys revoked, whatever they say. The
|
|
55
|
-
* unpairing is the authority, so any key may sign the request; a repeat answers as the first call did.
|
|
55
|
+
* unpairing is the authority, so any key may sign the request; a repeat answers as the first call did. An
|
|
56
|
+
* unpairing by a key the principal moved on from counts when `succession` leads from that key to the
|
|
57
|
+
* principal held here.
|
|
56
58
|
*/
|
|
57
59
|
export declare function unpair(ctx: Context, req: Request): Promise<Reply>;
|
|
58
60
|
/** Recovery status is reserved to a currently authorized agent key. */
|
package/dist/account.js
CHANGED
|
@@ -214,18 +214,22 @@ async function cancelByCode(ctx, req, address, cancelCode) {
|
|
|
214
214
|
}
|
|
215
215
|
/**
|
|
216
216
|
* A key principal ends its pairing: the address retires at once, its keys revoked, whatever they say. The
|
|
217
|
-
* unpairing is the authority, so any key may sign the request; a repeat answers as the first call did.
|
|
217
|
+
* unpairing is the authority, so any key may sign the request; a repeat answers as the first call did. An
|
|
218
|
+
* unpairing by a key the principal moved on from counts when `succession` leads from that key to the
|
|
219
|
+
* principal held here.
|
|
218
220
|
*/
|
|
219
221
|
export async function unpair(ctx, req) {
|
|
220
222
|
const now = ctx.now();
|
|
221
223
|
const headers = readSignedHeaders(req.headers, now, { agent: true, optionalAgent: true });
|
|
222
|
-
const body = readBody(req.body, ['address', 'unpairing']);
|
|
224
|
+
const body = readBody(req.body, ['address', 'unpairing', 'succession']);
|
|
223
225
|
const named = normalizeAddress(body.address);
|
|
224
226
|
checkActor(headers, named, now);
|
|
225
227
|
await verifyAnyKey(ctx, req, headers);
|
|
226
228
|
const agent = named === null ? null : await ctx.store.agentByAddress(named);
|
|
227
229
|
// One answer for every refusal, with nothing in it: a principal is shown only to its agent's recipients.
|
|
228
|
-
|
|
230
|
+
// Unpairing always wins (spec section 2.2): a key the principal moved on from unpairs too, given the succession
|
|
231
|
+
// from it to the principal held here.
|
|
232
|
+
if (!agent?.principal || !verifyUnpairing(agent.address, agent.principal, body.unpairing, body.succession)) {
|
|
229
233
|
throw unauthorized('bad_proof', "unpairing is the key principal's signature over SIMON-UNPAIR, the address and the principal.", now);
|
|
230
234
|
}
|
|
231
235
|
if (!agent.retiredAt) {
|
package/dist/deliver.js
CHANGED
|
@@ -111,7 +111,7 @@ export async function deliver(ctx, req) {
|
|
|
111
111
|
id,
|
|
112
112
|
message: m,
|
|
113
113
|
carrier: server,
|
|
114
|
-
claims: await judgeClaims(m, envelope.claims,
|
|
114
|
+
claims: await judgeClaims(m, envelope.claims, server, d => ctx.resolver.resolve(d, { charge: carrier })),
|
|
115
115
|
signedClaims: envelope.claims ? { claims: envelope.claims, signature: envelope.claimsSignature } : null,
|
|
116
116
|
receivedAt: iso(now),
|
|
117
117
|
expiresAt: later(now, ctx.config.retentionDays * DAY_MS),
|
package/dist/extensions.d.ts
CHANGED
|
@@ -104,7 +104,7 @@ export interface Upload {
|
|
|
104
104
|
bytes: Buffer;
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
* A reader asking for a published message whose `audience` header is `audience` (
|
|
107
|
+
* A reader asking for a published message whose `audience` header is `audience` (publishing profile, section 1). `reader`
|
|
108
108
|
* is the address that signed the request with a key of its own agent document, or null when nobody in
|
|
109
109
|
* particular asks.
|
|
110
110
|
*/
|
package/dist/notice.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* into the inbox: no policy stands between a server and its own agent.
|
|
6
6
|
*/
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
|
-
import { b64url, domainOf, fromB64url, keyToken, messageId, privateKeyFromSeed, signMessage, timestamp, } from '@agnt-sdk/simon-protocol';
|
|
8
|
+
import { b64url, domainOf, fromB64url, keyToken, messageId, privateKeyFromSeed, signMessage, timestamp, VERSION, } from '@agnt-sdk/simon-protocol';
|
|
9
9
|
import { iso, later } from "./context.js";
|
|
10
10
|
import { takeIntoInbox } from "./fanout.js";
|
|
11
11
|
/** The local part this server writes notices as, on every domain it serves; no agent may register it. */
|
|
@@ -39,7 +39,7 @@ export async function notify(ctx, address, text) {
|
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
41
|
const message = signMessage({
|
|
42
|
-
v:
|
|
42
|
+
v: VERSION,
|
|
43
43
|
from,
|
|
44
44
|
to: [address],
|
|
45
45
|
inReplyTo: null,
|
package/dist/parts.js
CHANGED
|
@@ -35,7 +35,7 @@ import { authenticateAgent, authenticateServer, isAgentSigned, isServerSigned }
|
|
|
35
35
|
import { iso, later } from "./context.js";
|
|
36
36
|
import { notFound, unavailable } from "./errors.js";
|
|
37
37
|
import { clientKey } from "./net/ip.js";
|
|
38
|
-
import { askerOf, asReader,
|
|
38
|
+
import { askerOf, asReader, isPublic, mayRead } from "./readers.js";
|
|
39
39
|
import { contentParts } from "./send.js";
|
|
40
40
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
41
41
|
export const PREFETCH_JITTER_MS = 3 * 60_000;
|
|
@@ -341,7 +341,7 @@ export function partRoutes(ctx) {
|
|
|
341
341
|
const reader = await viewer.published.reader();
|
|
342
342
|
// Try every unconditional grant before invoking an unrelated asynchronous audience policy.
|
|
343
343
|
// Yield lazily: read() can return held bytes without starting or retaining that policy work.
|
|
344
|
-
const unconditional = ({ message }) =>
|
|
344
|
+
const unconditional = ({ message }) => isPublic(message) || reader === message.from;
|
|
345
345
|
for (const { author } of namers.filter(unconditional)) {
|
|
346
346
|
for (const source of await ctx.store.heldFor(sha256, author, self))
|
|
347
347
|
yield source;
|
package/dist/public.js
CHANGED
package/dist/published.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Published messages: messages written here with an empty `to` (spec section 7), served to whoever their
|
|
3
3
|
* audience admits (readers.ts):
|
|
4
4
|
*
|
|
5
|
-
* GET /simon/messages/<id> the message and its
|
|
5
|
+
* GET /simon/messages/<id> the message, and its carrier's signed claims when it made any
|
|
6
6
|
* GET /simon/feeds/<address>/<feed> the author's published messages in that feed, newest first
|
|
7
7
|
* GET /simon/messages/<id>/related the relations to it marked listed that this server shows, newest first
|
|
8
8
|
*
|
|
9
9
|
* A message a reader may not see, an unknown one and a retired author's are answered alike: `404` for a
|
|
10
10
|
* message and its relations, an empty page for a feed. Listings page with an opaque cursor, `before`.
|
|
11
11
|
*/
|
|
12
|
-
import { header, invalid, MAX_CURSOR_LENGTH, MAX_LISTING_LIMIT, DEFAULT_LISTING_LIMIT,
|
|
12
|
+
import { header, invalid, MAX_CURSOR_LENGTH, MAX_LISTING_LIMIT, DEFAULT_LISTING_LIMIT, PUBLIC_RELATIONS, VERSION, } from '@agnt-sdk/simon-protocol';
|
|
13
13
|
import { notFound } from "./errors.js";
|
|
14
14
|
import { json } from "./http.js";
|
|
15
15
|
import { askerOf, mayRead } from "./readers.js";
|
|
@@ -18,7 +18,7 @@ import { askerOf, mayRead } from "./readers.js";
|
|
|
18
18
|
* scan that ends there ends the listing too: its next cursor would be a hidden message's.
|
|
19
19
|
*/
|
|
20
20
|
const MAX_SCANNED = 5000;
|
|
21
|
-
/** The feed of a published message that names none (
|
|
21
|
+
/** The feed of a published message that names none (publishing profile, section 3). */
|
|
22
22
|
const DEFAULT_FEED = 'posts';
|
|
23
23
|
const CURSOR = new RegExp(`^[\\x21-\\x7e]{1,${MAX_CURSOR_LENGTH}}$`);
|
|
24
24
|
const EMPTY = { messages: [], cursor: null, more: false };
|
|
@@ -71,7 +71,7 @@ async function listing(next, shows, before, limit) {
|
|
|
71
71
|
return { messages: page.map(s => s.message), cursor: more ? page.at(-1).cursor : null, more };
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
* Whether a relation to a published message is one its author's server lists (
|
|
74
|
+
* Whether a relation to a published message is one its author's server lists (publishing profile, section 4): its writer
|
|
75
75
|
* marked it `listed`, a plain reply never is; of those, the relations this server shows (PUBLIC_RELATIONS);
|
|
76
76
|
* and not retracted by its writer since.
|
|
77
77
|
*/
|
|
@@ -96,16 +96,11 @@ export function publishedRoutes(ctx) {
|
|
|
96
96
|
const record = await readable(req.params.id, await askerOf(ctx, req));
|
|
97
97
|
if (!record)
|
|
98
98
|
throw notFound('No published message has this id here.');
|
|
99
|
-
const author = (await ctx.store.agentByAddress(record.message.from));
|
|
100
|
-
const at = new Date(parseTimestamp(record.message.sentAt));
|
|
101
99
|
const claims = record.signedClaims
|
|
102
100
|
? { claims: record.signedClaims.claims, claimsSignature: record.signedClaims.signature }
|
|
103
101
|
: {};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
keys: author.keys.filter(k => validAt(k, at)).map(({ key, added, revoked }) => ({ key, added, revoked })),
|
|
107
|
-
...claims,
|
|
108
|
-
});
|
|
102
|
+
// No keys beside it: a reader takes them from the author's agent document, where they are signed.
|
|
103
|
+
return json({ message: record.message, ...claims });
|
|
109
104
|
},
|
|
110
105
|
},
|
|
111
106
|
{
|
package/dist/readers.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Who reads a published message, and whether its audience admits them (
|
|
2
|
+
* Who reads a published message, and whether its audience admits them (publishing profile, section 1).
|
|
3
3
|
*
|
|
4
4
|
* A reader proves who it is the way an author does: it signs the request with a key of its own agent
|
|
5
5
|
* document and names itself in `simon-agent` (section 8). A request that is not signed, or whose signature
|
|
6
6
|
* fails, asks as nobody in particular, and is answered exactly as one, so a refusal tells nothing about
|
|
7
7
|
* the message.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* `audience: public` is for anyone. Any other label is for whoever the extensions' `admitsReader` chain
|
|
10
|
+
* admits, and always its author; with no extension deciding, for no one but its author. A published message
|
|
11
|
+
* with no audience, which this version refuses, is its author's alone.
|
|
11
12
|
*/
|
|
12
13
|
import { type AnyMessage } from '@agnt-sdk/simon-protocol';
|
|
13
14
|
import type { Context } from './context.ts';
|
|
@@ -22,10 +23,17 @@ export declare const asReader: (address: string) => Asker;
|
|
|
22
23
|
* Who asks, settled before anything is looked up for the request: a request naming `simon-agent` is
|
|
23
24
|
* authenticated at once, its reader's document fetched and charged to the client, whatever it asks for and
|
|
24
25
|
* whether or not anything matches, so neither the answer nor the fetches it makes tell which ids, feeds or
|
|
25
|
-
* parts hold audience-limited posts (
|
|
26
|
+
* parts hold audience-limited posts (publishing profile, section 1).
|
|
26
27
|
*/
|
|
27
28
|
export declare function askerOf(ctx: Context, req: Request): Promise<Asker>;
|
|
28
|
-
/** The audience label
|
|
29
|
+
/** The audience label that admits anyone (publishing profile, section 1). */
|
|
30
|
+
export declare const PUBLIC = "public";
|
|
31
|
+
/** The audience label a published message of a version known here carries, or null. */
|
|
29
32
|
export declare const audienceOf: (m: AnyMessage) => string | null;
|
|
30
|
-
/** Whether a published message
|
|
33
|
+
/** Whether anyone may read a published message: its audience is `public`. */
|
|
34
|
+
export declare const isPublic: (m: AnyMessage) => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether a published message written here may be shown to this asker. A message without an audience
|
|
37
|
+
* label, which this version refuses, is its author's alone: closed, never open.
|
|
38
|
+
*/
|
|
31
39
|
export declare function mayRead(ctx: Context, m: AnyMessage, asker: Asker): Promise<boolean>;
|
package/dist/readers.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Who reads a published message, and whether its audience admits them (
|
|
2
|
+
* Who reads a published message, and whether its audience admits them (publishing profile, section 1).
|
|
3
3
|
*
|
|
4
4
|
* A reader proves who it is the way an author does: it signs the request with a key of its own agent
|
|
5
5
|
* document and names itself in `simon-agent` (section 8). A request that is not signed, or whose signature
|
|
6
6
|
* fails, asks as nobody in particular, and is answered exactly as one, so a refusal tells nothing about
|
|
7
7
|
* the message.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* `audience: public` is for anyone. Any other label is for whoever the extensions' `admitsReader` chain
|
|
10
|
+
* admits, and always its author; with no extension deciding, for no one but its author. A published message
|
|
11
|
+
* with no audience, which this version refuses, is its author's alone.
|
|
11
12
|
*/
|
|
12
13
|
import { header, VERSION, WireError } from '@agnt-sdk/simon-protocol';
|
|
13
14
|
import { authenticateReader, isAgentSigned } from "./auth.js";
|
|
@@ -18,7 +19,7 @@ export const asReader = (address) => ({ reader: async () => address });
|
|
|
18
19
|
* Who asks, settled before anything is looked up for the request: a request naming `simon-agent` is
|
|
19
20
|
* authenticated at once, its reader's document fetched and charged to the client, whatever it asks for and
|
|
20
21
|
* whether or not anything matches, so neither the answer nor the fetches it makes tell which ids, feeds or
|
|
21
|
-
* parts hold audience-limited posts (
|
|
22
|
+
* parts hold audience-limited posts (publishing profile, section 1).
|
|
22
23
|
*/
|
|
23
24
|
export async function askerOf(ctx, req) {
|
|
24
25
|
if (!isAgentSigned(req))
|
|
@@ -34,15 +35,24 @@ export async function askerOf(ctx, req) {
|
|
|
34
35
|
throw e;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
|
-
/** The audience label
|
|
38
|
+
/** The audience label that admits anyone (publishing profile, section 1). */
|
|
39
|
+
export const PUBLIC = 'public';
|
|
40
|
+
/** The audience label a published message of a version known here carries, or null. */
|
|
38
41
|
export const audienceOf = (m) => (m.v === VERSION ? header(m, 'audience') : null);
|
|
39
|
-
/** Whether a published message
|
|
42
|
+
/** Whether anyone may read a published message: its audience is `public`. */
|
|
43
|
+
export const isPublic = (m) => audienceOf(m) === PUBLIC;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a published message written here may be shown to this asker. A message without an audience
|
|
46
|
+
* label, which this version refuses, is its author's alone: closed, never open.
|
|
47
|
+
*/
|
|
40
48
|
export async function mayRead(ctx, m, asker) {
|
|
41
49
|
const audience = audienceOf(m);
|
|
42
|
-
if (audience ===
|
|
50
|
+
if (audience === PUBLIC)
|
|
43
51
|
return true;
|
|
44
52
|
const reader = await asker.reader();
|
|
45
53
|
if (reader === m.from)
|
|
46
54
|
return true;
|
|
55
|
+
if (audience === null)
|
|
56
|
+
return false;
|
|
47
57
|
return (await ctx.hooks.admitsReader({ message: m, audience, reader })) === 'admit';
|
|
48
58
|
}
|
package/dist/register.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* account (account.ts).
|
|
19
19
|
*/
|
|
20
20
|
import crypto from 'node:crypto';
|
|
21
|
-
import { b64url, domainOf, invalid, isPrincipal, isPublicKey, normalizeAddress, pairingBytes, parseTimestamp, principalKey, readSignedHeaders, SEND_WINDOW_MS, SENT_AT_FUTURE_MS, unauthorized, verifyPairing, verifyRecovery, WireError, } from '@agnt-sdk/simon-protocol';
|
|
21
|
+
import { b64url, domainOf, invalid, isPrincipal, isPublicKey, normalizeAddress, pairingBytes, parsePairing, parseTimestamp, principalKey, readSignedHeaders, SEND_WINDOW_MS, SENT_AT_FUTURE_MS, unauthorized, verifyPairing, verifyRecovery, WireError, } from '@agnt-sdk/simon-protocol';
|
|
22
22
|
import { account, canPublishAgent, CANCEL_CODE, keysMayCancel, matches, readBody, RECOVERY, sha256, } from "./account.js";
|
|
23
23
|
import { checkActor, verifyAnyKey } from "./auth.js";
|
|
24
24
|
import { iso, later } from "./context.js";
|
|
@@ -169,10 +169,9 @@ async function pairing(ctx, address, key, body) {
|
|
|
169
169
|
if (principalKey(principal) !== null) {
|
|
170
170
|
if (body.pairing === undefined)
|
|
171
171
|
return json({ status: 'pairing_required', address }, 202);
|
|
172
|
-
if (!verifyPairing(address, principal, body.pairing)) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
172
|
+
if (!verifyPairing(address, principal, body.pairing, ctx.now())) {
|
|
173
|
+
const until = parsePairing(body.pairing)?.until ?? '<until>';
|
|
174
|
+
throw unauthorized('bad_proof', "pairing is <until> <signature>: the principal key's signature over the pairing string, lapsing within 366 days from now.", ctx.now(), { pairingString: pairingBytes(address, principal, until) });
|
|
176
175
|
}
|
|
177
176
|
return principal;
|
|
178
177
|
}
|
package/dist/resolver.d.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* so it holds for every process sharing it). Only lookups made count: a cache hit, or a lookup another
|
|
24
24
|
* ask already has underway, costs nothing. Past either bound the lookup is `429`, and it is not counted.
|
|
25
25
|
*/
|
|
26
|
-
import { type
|
|
26
|
+
import { type KeyEntry, type Resolution, type ServerDocument } from '@agnt-sdk/simon-protocol';
|
|
27
27
|
import type { Config } from './config.ts';
|
|
28
28
|
import type { Log } from './extensions.ts';
|
|
29
29
|
import type { Dns } from './net/dns.ts';
|
|
@@ -52,7 +52,6 @@ export type Peer = {
|
|
|
52
52
|
outcome: Exclude<Resolution['outcome'], 'server'>;
|
|
53
53
|
};
|
|
54
54
|
export interface AgentKeys {
|
|
55
|
-
kind: AgentKind | 'unknown';
|
|
56
55
|
keys: KeyEntry[];
|
|
57
56
|
}
|
|
58
57
|
/** Counts one lookup against whoever caused it, before it is made; throws 429 when they have none left. */
|
package/dist/send.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* client API gives: the message as a delivery would check it (spec section 6.3, steps 1, 3 and 4); a
|
|
4
4
|
* version this server implements; `from` the agent's own address, on a domain that still names this
|
|
5
5
|
* server; the author's signature; the paired or solo rule; the 24-hour window; no message with an audience
|
|
6
|
-
* carried whole (spec section
|
|
6
|
+
* carried whole (spec section 5.4); every content-addressed
|
|
7
7
|
* part held for this agent, uploaded or received, so a part it received can be forwarded. Then the message,
|
|
8
8
|
* this server's claims about it signed for it (spec section 6.2), and one delivery row per recipient domain
|
|
9
9
|
* are stored as one, and the answer is given; delivery follows.
|
package/dist/send.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* client API gives: the message as a delivery would check it (spec section 6.3, steps 1, 3 and 4); a
|
|
4
4
|
* version this server implements; `from` the agent's own address, on a domain that still names this
|
|
5
5
|
* server; the author's signature; the paired or solo rule; the 24-hour window; no message with an audience
|
|
6
|
-
* carried whole (spec section
|
|
6
|
+
* carried whole (spec section 5.4); every content-addressed
|
|
7
7
|
* part held for this agent, uploaded or received, so a part it received can be forwarded. Then the message,
|
|
8
8
|
* this server's claims about it signed for it (spec section 6.2), and one delivery row per recipient domain
|
|
9
9
|
* are stored as one, and the answer is given; delivery follows.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* A repeat answers as the first call did, and stores again whatever rows a store lost, so a client that
|
|
12
12
|
* retries after a crash never leaves its message undelivered.
|
|
13
13
|
*/
|
|
14
|
-
import { carriedMessages, checkEnvelopeShape, claimsBytes, checkMessage, checkPrincipal, checkSupportedVersion, deliveryWindow, domainOf, invalid, isFresh, isJsonObject, judgeClaims, messageId, namedFiles, parseMessage, parseStrict, SEND_WINDOW_MS, sign, unauthorized, verifyMessage, } from '@agnt-sdk/simon-protocol';
|
|
14
|
+
import { carriedMessages, checkEnvelopeShape, claimsBytes, checkMessage, checkPrincipal, checkSupportedVersion, deliveryWindow, domainOf, header, invalid, isFresh, isJsonObject, judgeClaims, messageId, namedFiles, parseMessage, parseStrict, readSuccession, SEND_WINDOW_MS, sign, unauthorized, verifyMessage, VERSION, } from '@agnt-sdk/simon-protocol';
|
|
15
15
|
import { authenticateAgent } from "./auth.js";
|
|
16
16
|
import { iso, later } from "./context.js";
|
|
17
17
|
import { badRequest, conflict } from "./errors.js";
|
|
@@ -41,6 +41,8 @@ async function write(ctx, agent, m, hints) {
|
|
|
41
41
|
// The claims that travel, as the first call checked them: without this server's judgement.
|
|
42
42
|
checkHints(ctx, hints, m, earlier.signedClaims?.claims ?? []);
|
|
43
43
|
await ctx.store.putMessage(earlier, { deliveries: deliveriesFor(earlier, hints) });
|
|
44
|
+
// The first call may have stored the message and stopped before it moved the record.
|
|
45
|
+
await follow(ctx, agent, earlier.message);
|
|
44
46
|
ctx.wake();
|
|
45
47
|
return { id, receivedAt: earlier.receivedAt };
|
|
46
48
|
}
|
|
@@ -57,7 +59,7 @@ async function write(ctx, agent, m, hints) {
|
|
|
57
59
|
if (!verifyMessage(m, agent.keys)) {
|
|
58
60
|
throw unauthorized('bad_signature', "The message does not verify with the author's keys valid at its sentAt.", now);
|
|
59
61
|
}
|
|
60
|
-
checkPrincipal(m, agent.principal === null ? { kind: 'solo' } : { kind: 'paired', for: agent.principal });
|
|
62
|
+
checkPrincipal(m, agent.principal === null ? { kind: 'solo' } : { kind: 'paired', for: agent.principal }, now);
|
|
61
63
|
if (!isFresh(m, now, SEND_WINDOW_MS)) {
|
|
62
64
|
throw invalid('sentAt is more than 24 hours old or more than 5 minutes ahead.', { field: 'sentAt' });
|
|
63
65
|
}
|
|
@@ -68,12 +70,23 @@ async function write(ctx, agent, m, hints) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
const made = await ctx.hooks.claimsFor(m, agent);
|
|
71
|
-
const claims = await judgeClaims(m, made,
|
|
73
|
+
const claims = await judgeClaims(m, made, ctx.config.origin, d => ctx.resolver.resolve(d, { charge: payer }));
|
|
72
74
|
// What travels, and is signed for the message, is the claims considered, without this server's judgement.
|
|
73
75
|
const sent = claims.map(({ authoritative: _, ...c }) => c);
|
|
74
76
|
checkHints(ctx, hints, m, sent);
|
|
75
77
|
ctx.rate.hit('send', agent.address);
|
|
76
78
|
ctx.rate.distinct('destinations', agent.address, m.to.map(domainOf));
|
|
79
|
+
// A succession its principal signed moves a paired agent's principal on to the successor (spec section 2.2),
|
|
80
|
+
// once the message is stored, so a message that is not stored moves nothing. A concurrent send may have made
|
|
81
|
+
// the same move first; anything else that changed the record meanwhile refuses this one.
|
|
82
|
+
const forValue = m.v === VERSION ? header(m, 'for') : null;
|
|
83
|
+
const moving = agent.principal !== null && forValue !== null && forValue !== agent.principal;
|
|
84
|
+
if (moving) {
|
|
85
|
+
const held = (await ctx.store.agentByAddress(agent.address))?.principal;
|
|
86
|
+
if (held !== agent.principal && held !== forValue) {
|
|
87
|
+
throw conflict('principal_changed', "This agent's principal changed meanwhile: compose the message again.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
77
90
|
const record = {
|
|
78
91
|
id,
|
|
79
92
|
message: m,
|
|
@@ -84,10 +97,35 @@ async function write(ctx, agent, m, hints) {
|
|
|
84
97
|
expiresAt: later(now, ctx.config.retentionDays * DAY_MS),
|
|
85
98
|
};
|
|
86
99
|
const stored = await ctx.store.putMessage(record, { deliveries: deliveriesFor(record, hints) });
|
|
100
|
+
if (moving)
|
|
101
|
+
await follow(ctx, agent, m);
|
|
87
102
|
ctx.defer(() => ctx.hooks.emit('stored', { message: stored }));
|
|
88
103
|
await changed(ctx, agent.address);
|
|
89
104
|
return { id, receivedAt: stored.receivedAt };
|
|
90
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Move the agent's record on to the successor a stored message names along its succession from the principal
|
|
108
|
+
* held (spec section 2.2), as a compare-and-set. A record that changed otherwise since the message was checked
|
|
109
|
+
* stays as that change left it: the message was checked against the record as it was, as any message racing
|
|
110
|
+
* a change is.
|
|
111
|
+
*/
|
|
112
|
+
async function follow(ctx, agent, m) {
|
|
113
|
+
const from = agent.principal;
|
|
114
|
+
if (from === null || m.v !== VERSION)
|
|
115
|
+
return;
|
|
116
|
+
const to = header(m, 'for');
|
|
117
|
+
const succession = header(m, 'succession');
|
|
118
|
+
if (to === null || to === from || succession === null)
|
|
119
|
+
return;
|
|
120
|
+
if (!readSuccession(succession, to)?.some(step => step.from === from))
|
|
121
|
+
return;
|
|
122
|
+
if (await ctx.store.succeedPrincipal(agent.address, from, to)) {
|
|
123
|
+
ctx.log('info', 'principal succeeded', { address: agent.address });
|
|
124
|
+
}
|
|
125
|
+
else if ((await ctx.store.agentByAddress(agent.address))?.principal !== to) {
|
|
126
|
+
ctx.log('warn', 'principal changed while a move was stored', { address: agent.address });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
91
129
|
/** How often a send renews what it noted about serving its domain, at most. */
|
|
92
130
|
const SERVING_CHECKED_EVERY_MS = 10 * 60_000;
|
|
93
131
|
/**
|
|
@@ -112,15 +150,23 @@ async function notedServing(ctx, domain, now) {
|
|
|
112
150
|
}
|
|
113
151
|
}
|
|
114
152
|
/**
|
|
115
|
-
* A message
|
|
116
|
-
* carries one inline, at any depth, is refused as the message checks refuse a part (`400
|
|
117
|
-
*
|
|
153
|
+
* A message for a narrower audience than `public` is never forwarded or quoted whole (spec section 5.4): a
|
|
154
|
+
* message that carries one inline, at any depth, is refused as the message checks refuse a part (`400
|
|
155
|
+
* invalid`, `parts`). Narrower means an `audience` other than `public`, whatever the carried message's
|
|
156
|
+
* version (one that cannot tell keeps it to itself), or a message of this version to no one with no
|
|
157
|
+
* audience, which is its author's alone. One carried by content address is not seen here; its bytes are the
|
|
158
|
+
* client's to keep to itself.
|
|
118
159
|
*/
|
|
119
160
|
function checkCarriedAudience(m) {
|
|
120
161
|
for (const inner of carriedMessages(m)) {
|
|
121
162
|
const headers = inner.headers;
|
|
122
|
-
if (
|
|
123
|
-
|
|
163
|
+
if (!isJsonObject(headers))
|
|
164
|
+
continue;
|
|
165
|
+
const narrower = Object.hasOwn(headers, 'audience')
|
|
166
|
+
? headers.audience !== 'public'
|
|
167
|
+
: inner.v === VERSION && Array.isArray(inner.to) && inner.to.length === 0;
|
|
168
|
+
if (narrower) {
|
|
169
|
+
throw invalid('A message for a narrower audience than public is never forwarded or quoted whole (spec section 5.4).', {
|
|
124
170
|
field: 'parts',
|
|
125
171
|
});
|
|
126
172
|
}
|
package/dist/server.js
CHANGED
|
@@ -74,7 +74,7 @@ export function createServer(opts) {
|
|
|
74
74
|
ownDocument: () => (docCache ??= ownDocument(ctx)),
|
|
75
75
|
localAgent: async (address) => {
|
|
76
76
|
const agent = await store.agentByAddress(address);
|
|
77
|
-
return agent && !agent.retiredAt ? {
|
|
77
|
+
return agent && !agent.retiredAt ? { keys: agent.keys } : null;
|
|
78
78
|
},
|
|
79
79
|
publicSuffixes: config.publicSuffixFile ? loadPublicSuffixes(config.publicSuffixFile) : undefined,
|
|
80
80
|
chargeCarrier: carrier => ctx.rate.hit('carrierLookup', carrier),
|
package/dist/store/contract.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
11
|
+
import { VERSION } from '@agnt-sdk/simon-protocol';
|
|
11
12
|
import { MAX_STORED_KEYS } from "./store.js";
|
|
12
13
|
const T0 = Date.parse('2026-10-15T00:00:00.000Z');
|
|
13
14
|
const at = (ms = 0) => new Date(T0 + ms).toISOString();
|
|
@@ -26,7 +27,7 @@ const agent = (address, ...keys) => ({
|
|
|
26
27
|
let n = 0;
|
|
27
28
|
function message(from, to, extra = {}) {
|
|
28
29
|
const m = {
|
|
29
|
-
v:
|
|
30
|
+
v: VERSION,
|
|
30
31
|
from,
|
|
31
32
|
to,
|
|
32
33
|
inReplyTo: null,
|
|
@@ -92,6 +93,17 @@ export function storeContract(name, make) {
|
|
|
92
93
|
assert.equal(retired?.unpairing ?? null, null);
|
|
93
94
|
assert.equal(await store.createAgent(agent('ada@a.test', 'k3')), 'address_taken');
|
|
94
95
|
});
|
|
96
|
+
it("moves a paired agent's principal to its successor only from the principal it holds, never once retired", async () => {
|
|
97
|
+
await store.createAgent({ ...agent('ada@a.test', 'k1'), kind: 'paired', principal: 'simon-key:p1' });
|
|
98
|
+
assert.equal(await store.succeedPrincipal('ada@a.test', 'simon-key:p0', 'simon-key:p2'), false, 'stale');
|
|
99
|
+
assert.equal(await store.succeedPrincipal('ada@a.test', 'simon-key:p1', 'simon-key:p2'), true);
|
|
100
|
+
assert.equal((await store.agentByAddress('ada@a.test'))?.principal, 'simon-key:p2');
|
|
101
|
+
assert.equal(await store.succeedPrincipal('ada@a.test', 'simon-key:p1', 'simon-key:p3'), false, 'a fork');
|
|
102
|
+
assert.equal(await store.succeedPrincipal('bob@a.test', 'simon-key:p1', 'simon-key:p2'), false, 'no agent');
|
|
103
|
+
await store.retireAgent('ada@a.test', at(1));
|
|
104
|
+
assert.equal(await store.succeedPrincipal('ada@a.test', 'simon-key:p2', 'simon-key:p3'), false, 'retired');
|
|
105
|
+
assert.equal((await store.agentByAddress('ada@a.test'))?.principal, 'simon-key:p2');
|
|
106
|
+
});
|
|
95
107
|
it('keeps the unpairing that retired an address, and only the first retirement', async () => {
|
|
96
108
|
await store.createAgent(agent('ada@a.test', 'k1'));
|
|
97
109
|
await store.retireAgent('ada@a.test', at(1), 'UNPAIRING');
|
|
@@ -7,7 +7,7 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { backup, DatabaseSync } from 'node:sqlite';
|
|
10
|
-
import { canonicalize, checkMessage, isAddress, isImplemented, isPrincipal, fromB64url, normalizeDomain, readKeys, serverOrigin, isPublicKey, MAX_DOCUMENT_BYTES, MAX_DOCUMENT_KEYS, messageId, namedFiles, parseJson, parseKeyToken, parseTimestamp, } from '@agnt-sdk/simon-protocol';
|
|
10
|
+
import { canonicalize, checkMessage, isAddress, isImplemented, isPrincipal, fromB64url, normalizeDomain, readKeys, serverOrigin, isPublicKey, MAX_DOCUMENT_BYTES, MAX_DOCUMENT_KEYS, messageId, namedFiles, parseJson, parseKeyToken, parseTimestamp, VERSION, } from '@agnt-sdk/simon-protocol';
|
|
11
11
|
import { listedKeys } from "../public.js";
|
|
12
12
|
import { SqliteStore } from "./sqlite.js";
|
|
13
13
|
import { resolveRetractionCapacity } from "./store.js";
|
|
@@ -519,7 +519,7 @@ function precomputedReceipts(data, messages, limits) {
|
|
|
519
519
|
const receipts = Object.create(null);
|
|
520
520
|
for (const row of data.messages) {
|
|
521
521
|
const message = messages.get(row.id);
|
|
522
|
-
if (message.v !==
|
|
522
|
+
if (message.v !== VERSION)
|
|
523
523
|
continue;
|
|
524
524
|
const v1 = message;
|
|
525
525
|
if (v1.headers.rel !== 'retract' || v1.inReplyTo === null)
|
|
@@ -854,7 +854,7 @@ export async function createSqliteV2Candidate(options) {
|
|
|
854
854
|
const message = messages.get(row.id);
|
|
855
855
|
extra = {
|
|
856
856
|
...extra,
|
|
857
|
-
in_reply_to: message.v ===
|
|
857
|
+
in_reply_to: message.v === VERSION ? message.inReplyTo : null,
|
|
858
858
|
signed_claims: null,
|
|
859
859
|
cursor: null,
|
|
860
860
|
taken: null,
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare class SqliteStore implements Store {
|
|
|
13
13
|
revokeKey(address: string, key: string, at: string, admit?: AgentAdmission): Promise<"not_found" | "revoked" | "last_key">;
|
|
14
14
|
replaceKeys(address: string, entry: AgentKey, admit?: AgentAdmission): Promise<"retired" | "key_in_use" | "too_many_keys" | "replaced">;
|
|
15
15
|
keepOnlyKey(address: string, key: string, at: string): Promise<"kept" | "not_held">;
|
|
16
|
+
succeedPrincipal(address: string, from: string, to: string): Promise<boolean>;
|
|
16
17
|
retireAgent(address: string, at: string, unpairing?: string): Promise<void>;
|
|
17
18
|
beginRecovery(address: string, value: unknown, expiresAt: string | null, now: string, admit?: AgentAdmission): Promise<unknown>;
|
|
18
19
|
activateRecovery(address: string, expectedRecovery: unknown, now: string): Promise<boolean>;
|
package/dist/store/sqlite.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* the file. Times are stored as epoch milliseconds, so comparisons never depend on how a timestamp is spelled.
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { canonicalize, header, namedFiles } from '@agnt-sdk/simon-protocol';
|
|
7
|
+
import { canonicalize, header, namedFiles, VERSION } from '@agnt-sdk/simon-protocol';
|
|
8
8
|
import { MAX_STORED_KEYS, StoreCapacityError, resolveRetractionCapacity } from "./store.js";
|
|
9
9
|
/** The schema's version, kept in `PRAGMA user_version`. A file made by another version is refused. */
|
|
10
10
|
const SCHEMA_VERSION = 9;
|
|
@@ -76,7 +76,7 @@ const uniqueSources = (sources) => [
|
|
|
76
76
|
...new Map(sources.map(s => [`${'uploader' in s ? 'u' : 'c'} ${sourceKey(s)} ${s.size}`, s])).values(),
|
|
77
77
|
];
|
|
78
78
|
/** A version 1 message's `inReplyTo`. Other versions point at nothing the server can see. */
|
|
79
|
-
const inReplyTo = (m) =>
|
|
79
|
+
const inReplyTo = (m) => m.v === VERSION ? (m.inReplyTo ?? null) : null;
|
|
80
80
|
/** A page of rows, newest first, fetched one beyond `limit` to tell whether there are more. */
|
|
81
81
|
const page = (rows, limit, record) => ({
|
|
82
82
|
records: rows.slice(0, limit).map(r => ({ cursor: r.id, message: record(r) })),
|
|
@@ -332,6 +332,15 @@ export class SqliteStore {
|
|
|
332
332
|
return 'kept';
|
|
333
333
|
});
|
|
334
334
|
}
|
|
335
|
+
async succeedPrincipal(address, from, to) {
|
|
336
|
+
return this.#tx(() => {
|
|
337
|
+
const sql = 'UPDATE agents SET principal = ? WHERE address = ? AND principal = ? AND retired_at IS NULL';
|
|
338
|
+
if (this.#run(sql, to, address, from).changes !== 1)
|
|
339
|
+
return false;
|
|
340
|
+
this.#bumpAuthority(address);
|
|
341
|
+
return true;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
335
344
|
async retireAgent(address, at, unpairing) {
|
|
336
345
|
this.#tx(() => {
|
|
337
346
|
if (!this.#get('SELECT 1 FROM agents WHERE address = ? AND retired_at IS NULL', address))
|
|
@@ -422,7 +431,7 @@ export class SqliteStore {
|
|
|
422
431
|
});
|
|
423
432
|
}
|
|
424
433
|
#isRetraction(record) {
|
|
425
|
-
return (record.message.v ===
|
|
434
|
+
return (record.message.v === VERSION &&
|
|
426
435
|
header(record.message, 'rel') === 'retract' &&
|
|
427
436
|
inReplyTo(record.message) !== null);
|
|
428
437
|
}
|
package/dist/store/store.d.ts
CHANGED
|
@@ -201,6 +201,11 @@ export interface Store {
|
|
|
201
201
|
/** Recovery is atomic with retirement, uniqueness and final signed-document admission. */
|
|
202
202
|
replaceKeys(address: string, entry: AgentKey, admit?: AgentAdmission): Promise<'replaced' | 'key_in_use' | 'retired' | 'too_many_keys'>;
|
|
203
203
|
keepOnlyKey(address: string, key: string, at: string): Promise<'kept' | 'not_held'>;
|
|
204
|
+
/**
|
|
205
|
+
* Move a paired agent's principal from `from` to `to`, its successor (spec section 2.2), atomically: only
|
|
206
|
+
* while the agent is not retired and its principal is still `from`. False when it is not.
|
|
207
|
+
*/
|
|
208
|
+
succeedPrincipal(address: string, from: string, to: string): Promise<boolean>;
|
|
204
209
|
/** Only the first retirement counts, including its unpairing evidence. */
|
|
205
210
|
retireAgent(address: string, at: string, unpairing?: string): Promise<void>;
|
|
206
211
|
/** Begin only against the current admitted authority; return its existing live pending proof or the new one. */
|
|
@@ -237,8 +242,8 @@ export interface Store {
|
|
|
237
242
|
published(author: string, before: string | null, limit: number): Promise<Page>;
|
|
238
243
|
/**
|
|
239
244
|
* The messages in `address`'s inbox whose `inReplyTo` is `id`, newest first, after the one whose id is
|
|
240
|
-
* `before` (null for the newest): the relations to a published message its author may show (
|
|
241
|
-
*
|
|
245
|
+
* `before` (null for the newest): the relations to a published message its author may show (publishing
|
|
246
|
+
* profile, section 4). A record's cursor is its message id.
|
|
242
247
|
*/
|
|
243
248
|
repliesTo(address: string, id: string, before: string | null, limit: number): Promise<Page>;
|
|
244
249
|
/** Durable same-author rel:retract suppression for a live listing, scoped to its owner's accepted inbox. */
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Reply, Request } from './http.ts';
|
|
2
2
|
import { type Dns } from './net/dns.ts';
|
|
3
3
|
import { type Http, type HttpRequest, type HttpResponse } from './net/fetch.ts';
|
|
4
|
+
/** A pairing's until a year after the test clock starts, so it counts at the clock's time, not the wall's. */
|
|
5
|
+
export declare const CLOCK_PAIRING_UNTIL = "2027-10-15T12:00:00.000Z";
|
|
4
6
|
export declare class Clock {
|
|
5
7
|
t: number;
|
|
6
8
|
now: () => Date;
|
package/dist/testing.js
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
import { canonicalize } from '@agnt-sdk/simon-protocol';
|
|
13
13
|
import { DnsFailure } from "./net/dns.js";
|
|
14
14
|
import { Unreachable } from "./net/fetch.js";
|
|
15
|
+
const CLOCK_START = '2026-10-15T12:00:00.000Z';
|
|
16
|
+
/** A pairing's until a year after the test clock starts, so it counts at the clock's time, not the wall's. */
|
|
17
|
+
export const CLOCK_PAIRING_UNTIL = '2027-10-15T12:00:00.000Z';
|
|
15
18
|
export class Clock {
|
|
16
|
-
t = Date.parse(
|
|
19
|
+
t = Date.parse(CLOCK_START);
|
|
17
20
|
now = () => new Date(this.t);
|
|
18
21
|
advance(ms) {
|
|
19
22
|
this.t += ms;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agnt-sdk/simon-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The SIMON reference server: the whole protocol and client API, hardened, with Node's standard library only, and the Store contract any storage proves itself with.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"provenance": true
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@agnt-sdk/simon-protocol": "0.
|
|
56
|
+
"@agnt-sdk/simon-protocol": "0.2.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "1606981305ede14c02ddfdac9409b45b3a91a903"
|
|
59
59
|
}
|