@agnt-sdk/simon-server 0.1.0 → 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 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
- if (!agent?.principal || !verifyUnpairing(agent.address, agent.principal, body.unpairing)) {
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, author.kind, server, d => ctx.resolver.resolve(d, { charge: carrier })),
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),
@@ -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` (spec section 7.1). `reader`
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: 1,
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, audienceOf, mayRead } from "./readers.js";
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 }) => audienceOf(message) === null || reader === message.from;
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
@@ -62,7 +62,6 @@ export function agentDocument(ctx, agent) {
62
62
  for (;;) {
63
63
  const doc = signAgentDocument({
64
64
  address: agent.address,
65
- kind: agent.kind,
66
65
  keys,
67
66
  ...(agent.retiredAt && {
68
67
  retired: retiredAt(agent.retiredAt, keys),
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 author's keys valid at its `sentAt`
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, parseTimestamp, PUBLIC_RELATIONS, validAt, VERSION, } from '@agnt-sdk/simon-protocol';
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 (spec section 7.3). */
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 (spec section 7.4): its writer
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
- return json({
105
- message: record.message,
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 (spec section 7.1).
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
- * A message with no `audience` header is for anyone. One with a label is for whoever the extensions'
10
- * `admitsReader` chain admits; with no extension deciding, for no one but its author.
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 (spec section 7.1).
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 a published version 1 message carries, or null for anyone. */
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 written here may be shown to this asker. */
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 (spec section 7.1).
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
- * A message with no `audience` header is for anyone. One with a label is for whoever the extensions'
10
- * `admitsReader` chain admits; with no extension deciding, for no one but its author.
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 (spec section 7.1).
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 a published version 1 message carries, or null for anyone. */
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 written here may be shown to this asker. */
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 === null)
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
- throw unauthorized('bad_proof', "pairing is the principal key's signature over the pairing string.", ctx.now(), {
174
- pairingString: pairingBytes(address, principal),
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
  }
@@ -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 AgentKind, type KeyEntry, type Resolution, type ServerDocument } from '@agnt-sdk/simon-protocol';
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 7.1); every content-addressed
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 7.1); every content-addressed
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, agent.kind, ctx.config.origin, d => ctx.resolver.resolve(d, { charge: payer }));
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 with an `audience` header is never forwarded or quoted whole (spec section 7.1): a message that
116
- * carries one inline, at any depth, is refused as the message checks refuse a part (`400 invalid`, `parts`).
117
- * One carried by content address is not seen here; its bytes are the client's to keep to itself.
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 (inner.v === 1 && isJsonObject(headers) && Object.hasOwn(headers, 'audience')) {
123
- throw invalid('A message with an audience is never forwarded or quoted whole (spec section 7.1).', {
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 ? { kind: agent.kind, keys: agent.keys } : null;
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),
@@ -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: 1,
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 !== 1)
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 === 1 ? message.inReplyTo : null,
857
+ in_reply_to: message.v === VERSION ? message.inReplyTo : null,
858
858
  signed_claims: null,
859
859
  cursor: null,
860
860
  taken: null,
@@ -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>;
@@ -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) => (m.v === 1 ? (m.inReplyTo ?? null) : null);
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 === 1 &&
434
+ return (record.message.v === VERSION &&
426
435
  header(record.message, 'rel') === 'retract' &&
427
436
  inReplyTo(record.message) !== null);
428
437
  }
@@ -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 (spec section
241
- * 7.4). A record's cursor is its message id.
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('2026-10-15T12:00:00.000Z');
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.1.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.1.0"
56
+ "@agnt-sdk/simon-protocol": "0.2.0"
57
57
  },
58
- "gitHead": "0047e948e09b664b52ab2ef690a537e0f7d9ba76"
58
+ "gitHead": "1606981305ede14c02ddfdac9409b45b3a91a903"
59
59
  }