@ours.network/messenger-server 1.0.31-nightly.13 → 1.0.31-nightly.15

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/README.md CHANGED
@@ -411,3 +411,27 @@ The older mock walkthrough and its browser tests describe an earlier design stag
411
411
  Set `OURS_NOTIFICATIONS_ORIGIN` and a Messenger-scoped `OURS_NOTIFICATIONS_PRODUCER_TOKEN` to forward incoming messages/files to [ours-notifications](https://github.com/adapt-toolkit/ours-notifications). Both settings must be provided together. The canonical watcher durably queues notifications even without a local push binding, freezes projected content before its first HTTP attempt, and retries the same payload after outages/restarts. Keep the Messenger state directory across restarts. When configured, new events use the dedicated service; legacy pending push jobs may finish. Existing standalone push endpoints remain for compatibility.
412
412
 
413
413
  Workspace enrollment supports optional `preserveProfile: true` on its protected POST endpoint. This skips human profile creation/replacement (including an absent profile); root checks and the signed binding command remain mandatory. The protected enrollment-identity response advertises `preserveProfile: true` so clients can fail closed against older servers. Omitted/false keeps fresh-host account profile setup.
414
+
415
+ Messenger may run as the Human root or as a permanent identity under it. For the latter, the enrollment-identity response also carries `rootCid`, the root the daemon describes for the bound identity; `POST /api/identity/profile` accepts both. Only a Messenger bound to the root itself signs the workspace binding on `POST /api/workspace/enroll`; for an identity under the root the caller obtains that signature from the root outside Messenger.
416
+
417
+ After successful contact removal, the notification producer durably retires that
418
+ conversation through the notification service's scoped `/api/v1/delete-target`
419
+ route. Pending sends for that contact are removed; cleanup follows any already
420
+ inflight send and retries after service outages or restart. Watch replay confirms
421
+ absence against a complete contacts/pending inventory before dropping an event.
422
+ Inventory errors preserve the queue. The gateway and notification service must
423
+ support this producer lifecycle route before source cleanup can be delivered.
424
+
425
+ Profile-preserving workspace enrollment reuses an already authenticated contact
426
+ with the exact pinned enrollment server CID. This permits a fresh signed account
427
+ challenge after the one-time invitation was used for registration retirement;
428
+ account-side invitation provenance or an exact signed replacement handoff still
429
+ controls proof acceptance.
430
+
431
+ `POST /api/workspace/unregister` carries the first-party retirement command when
432
+ Messenger already holds the Human root lease. It requires local API/CSRF
433
+ authentication, a permanent root matching `rootCid`, the exact ready enrollment
434
+ server contact, and well-formed workspace/host/nonce identifiers. An optional
435
+ three-field replacement challenge is forwarded unchanged. The account service
436
+ owns retirement authorization and cleanup; this route only sends the signed
437
+ command and acknowledges submission.
@@ -38,7 +38,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
38
38
  var define_MESSENGER_BUILD_INFO_default;
39
39
  var init_define_MESSENGER_BUILD_INFO = __esm({
40
40
  "<define:__MESSENGER_BUILD_INFO__>"() {
41
- define_MESSENGER_BUILD_INFO_default = { name: "@ours.network/messenger-server", version: "1.0.31-nightly.13", sha: "576eb18c8545184db893e09c4dfa4544bb42ba41", dirty: true };
41
+ define_MESSENGER_BUILD_INFO_default = { name: "@ours.network/messenger-server", version: "1.0.31-nightly.15", sha: "67be7f7cdc2c8fb8d901fb2cb97920da2af048de", dirty: true };
42
42
  }
43
43
  });
44
44
 
@@ -10,7 +10,7 @@ import {
10
10
  publicEngineError,
11
11
  publicInternalError,
12
12
  reportFailure
13
- } from "./chunk-S6BYVM5Q.js";
13
+ } from "./chunk-YU46BKJ2.js";
14
14
 
15
15
  // node_modules/bn.js/lib/bn.js
16
16
  var require_bn = __commonJS({
@@ -11131,11 +11131,12 @@ function producerConfig(env = process.env) {
11131
11131
  }
11132
11132
  var NotificationOutbox = class {
11133
11133
  constructor(file, config, project, warn = () => {
11134
- }) {
11134
+ }, shouldRetire) {
11135
11135
  this.file = file;
11136
11136
  this.config = config;
11137
11137
  this.project = project;
11138
11138
  this.warn = warn;
11139
+ this.shouldRetire = shouldRetire;
11139
11140
  mkdirSync(dirname(file), { recursive: true, mode: 448 });
11140
11141
  const saved = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { entries: [] };
11141
11142
  this.entries = Array.isArray(saved) ? saved : saved.entries;
@@ -11186,6 +11187,12 @@ var NotificationOutbox = class {
11186
11187
  this.persist([...this.entries, { id, value }], cursor);
11187
11188
  return true;
11188
11189
  }
11190
+ retireTarget(url, matches) {
11191
+ const id = `delete-target:${url}`;
11192
+ const retained = this.entries.filter((entry) => entry.operation === "delete-target" || !matches(entry.value, entry.payload));
11193
+ if (!retained.some((entry) => entry.id === id)) retained.push({ id, value: { url }, operation: "delete-target" });
11194
+ this.persist(retained);
11195
+ }
11189
11196
  drain() {
11190
11197
  return this.running ??= this.drainOnce().finally(() => {
11191
11198
  this.running = void 0;
@@ -11194,9 +11201,15 @@ var NotificationOutbox = class {
11194
11201
  async drainOnce() {
11195
11202
  for (const entry of [...this.entries].slice(0, 32)) {
11196
11203
  try {
11197
- const value = entry.payload ?? await this.project(entry.value, entry.id);
11198
- if (!entry.payload) this.persist(this.entries.map((e2) => e2.id === entry.id ? { ...e2, payload: value } : e2));
11199
- const response = await fetch(this.config.origin + "/api/v1/send", {
11204
+ if (!this.entries.some((current) => current.id === entry.id)) continue;
11205
+ const value = entry.operation === "delete-target" ? this.shouldRetire && !await this.shouldRetire(entry.value.url) ? void 0 : entry.value : entry.payload ?? await this.project(entry.value, entry.id);
11206
+ if (!this.entries.some((current) => current.id === entry.id)) continue;
11207
+ if (value === void 0) {
11208
+ this.persist(this.entries.filter((current) => current.id !== entry.id));
11209
+ continue;
11210
+ }
11211
+ if (!entry.operation && !entry.payload) this.persist(this.entries.map((e2) => e2.id === entry.id ? { ...e2, payload: value } : e2));
11212
+ const response = await fetch(this.config.origin + (entry.operation === "delete-target" ? "/api/v1/delete-target" : "/api/v1/send"), {
11200
11213
  method: "POST",
11201
11214
  redirect: "error",
11202
11215
  signal: AbortSignal.timeout(1e4),
@@ -11643,8 +11656,15 @@ var PushDeliveryQueue = class {
11643
11656
  var MessengerNotificationProducer = class {
11644
11657
  constructor(stateDir, config, identityCid, client, warn) {
11645
11658
  this.identityCid = identityCid;
11659
+ const knownContact = async (cid) => {
11660
+ if (!client.listContacts) return void 0;
11661
+ const view = await client.listContacts();
11662
+ if (!Array.isArray(view.contacts) || !Array.isArray(view.pending) || [...view.contacts, ...view.pending].some((contact) => typeof contact?.container_id !== "string")) throw Error("Contact inventory unavailable");
11663
+ return [...view.contacts, ...view.pending].some((contact) => contact.container_id.toLowerCase() === cid.toLowerCase());
11664
+ };
11646
11665
  this.outbox = new NotificationOutbox(join(stateDir, "notification-outbox.json"), config, async (value, eventId) => {
11647
11666
  const record = value;
11667
+ if (await knownContact(record.sender_id) === false) return;
11648
11668
  const event = await projectPushEvent(client, {
11649
11669
  contactId: record.sender_id,
11650
11670
  wireId: record.wire_id,
@@ -11657,7 +11677,7 @@ var MessengerNotificationProducer = class {
11657
11677
  body: event.body.slice(0, 512) || "New message",
11658
11678
  url: `/fleet/chats?source=messenger&chat=${encodeURIComponent(record.sender_id)}&detail=1#chat-message-${encodeURIComponent(record.wire_id)}`
11659
11679
  };
11660
- }, warn);
11680
+ }, warn, async (url) => await knownContact(new URL(url, "https://ours.invalid").searchParams.get("chat")) !== true);
11661
11681
  }
11662
11682
  outbox;
11663
11683
  admit(record) {
@@ -11670,6 +11690,21 @@ var MessengerNotificationProducer = class {
11670
11690
  });
11671
11691
  return { status: admitted ? "queued" : "saturated" };
11672
11692
  }
11693
+ retireContact(cid) {
11694
+ const url = `/fleet/chats?source=messenger&chat=${encodeURIComponent(cid)}`;
11695
+ this.outbox.retireTarget(url, (value, payload) => {
11696
+ const record = value;
11697
+ if (typeof record?.sender_id === "string" && record.sender_id.toLowerCase() === cid.toLowerCase()) return true;
11698
+ if (!payload) return false;
11699
+ try {
11700
+ const target = new URL(payload.url, "https://ours.invalid");
11701
+ return target.pathname === "/fleet/chats" && target.searchParams.get("source") === "messenger" && target.searchParams.get("chat")?.toLowerCase() === cid.toLowerCase();
11702
+ } catch {
11703
+ return false;
11704
+ }
11705
+ });
11706
+ void this.outbox.drain();
11707
+ }
11673
11708
  close() {
11674
11709
  return this.outbox.close();
11675
11710
  }
@@ -20622,6 +20657,15 @@ function inlineBase64(body) {
20622
20657
  }
20623
20658
  return value;
20624
20659
  }
20660
+ function humanIdentity(value) {
20661
+ if (value === null || typeof value !== "object") return void 0;
20662
+ const identity = value;
20663
+ if (identity.temporary !== false || typeof identity.cid !== "string") return void 0;
20664
+ if (identity.isRoot === true) return {};
20665
+ const rootCid = identity.rootCid;
20666
+ if (identity.isRoot !== false || identity.described !== true || typeof identity.roleId !== "string" || !identity.roleId || typeof rootCid !== "string" || !/^[a-f0-9]{64}$/i.test(rootCid) || rootCid.toLowerCase() === identity.cid.toLowerCase()) return void 0;
20667
+ return { rootCid };
20668
+ }
20625
20669
  function publicIdentity(value) {
20626
20670
  if (value === null || typeof value !== "object") return {};
20627
20671
  const identity = value;
@@ -20990,7 +21034,13 @@ var ROUTES = {
20990
21034
  // operation: roots ride along on the contacts view.
20991
21035
  "GET /api/contacts/roots": async ({ client }) => ({ roots: (await client.listContacts()).roots }),
20992
21036
  "POST /api/contacts/add": async ({ client, body }) => client.addContact({ invite: str(body, "invite"), name: optStr(body, "name") }),
20993
- "POST /api/contacts/remove": async ({ client, body }) => client.removeContact({ contact: str(body, "contact") }),
21037
+ "POST /api/contacts/remove": async ({ client, body, deps }) => {
21038
+ const contact = str(body, "contact");
21039
+ const canonical3 = deps.retireContactNotifications ? (await resolveContact(client, contact)).cid : contact;
21040
+ const result = await client.removeContact({ contact: canonical3 });
21041
+ deps.retireContactNotifications?.(canonical3);
21042
+ return result;
21043
+ },
20994
21044
  "POST /api/contacts/rename": async ({ client, body }) => client.renameContact({ contact: str(body, "contact"), name: str(body, "name") }),
20995
21045
  "GET /api/contacts/local-book": async ({ client }) => client.listLocalContactBook(),
20996
21046
  "POST /api/contacts/local-book/policy": async ({ client, body }) => client.setLocalBookPolicy({ expose: optBool(body, "expose"), auto_accept: optBool(body, "auto_accept") }),
@@ -21017,15 +21067,42 @@ var ROUTES = {
21017
21067
  // field on this result.
21018
21068
  "GET /api/identity": async ({ client, deps }) => ({ ...publicIdentity(await client.currentIdentity()), humanProfile: readHumanProfile(deps.config.stateDir, deps.identityCid) }),
21019
21069
  "POST /api/identity/profile": async ({ client, deps, body }) => {
21020
- const identity = await client.currentIdentity();
21021
- if (!identity.isRoot || identity.temporary) throw bad("Human profile requires the root identity");
21070
+ if (!humanIdentity(await client.currentIdentity())) throw bad("Human profile requires the Human root or a permanent identity under it");
21022
21071
  return writeHumanProfile(deps.config.stateDir, deps.identityCid, { name: profileName(body.name), surname: profileName(body.surname) });
21023
21072
  },
21024
- // First-party setup operation: daemon signs the command using this bound root.
21073
+ // Who the person is on this host. A Messenger that runs as the Human root can sign the workspace proof
21074
+ // itself; one that runs as the person's own identity under that root names the root the daemon describes
21075
+ // for it, and the proof is then signed by that root outside Messenger.
21025
21076
  "GET /api/workspace/enrollment-identity": async ({ client }) => {
21077
+ const identity = await client.currentIdentity(), human = humanIdentity(identity);
21078
+ if (!human) throw bad("Workspace proof requires the bound Human root or a permanent identity under it");
21079
+ return human.rootCid === void 0 ? { cid: identity.cid, preserveProfile: true } : { cid: identity.cid, rootCid: human.rootCid, preserveProfile: true };
21080
+ },
21081
+ // First-party setup operation: daemon signs the command using this bound root.
21082
+ "POST /api/workspace/unregister": async ({ client, body }) => {
21026
21083
  const identity = await client.currentIdentity();
21027
- if (!identity.isRoot || identity.temporary) throw bad("Workspace proof requires the bound Human root");
21028
- return { cid: identity.cid, preserveProfile: true };
21084
+ if (!identity.isRoot || identity.temporary) throw bad("Workspace retirement requires the bound Human root");
21085
+ const rootCid = str(body, "rootCid").toUpperCase(), serverCid = str(body, "serverCid").toUpperCase();
21086
+ if (!/^[A-F0-9]{64}$/.test(rootCid) || !/^[A-F0-9]{64}$/.test(serverCid) || identity.cid.toUpperCase() !== rootCid) throw bad("Workspace retirement identity mismatch");
21087
+ const command = { type: "ours.app.unregister-workspace.v1" };
21088
+ for (const key of ["workspaceId", "hostWorkspaceId", "operationNonce"]) {
21089
+ const value = str(body, key);
21090
+ if (!/^[A-Za-z0-9_-]{43}$/.test(value)) throw bad("Invalid workspace retirement");
21091
+ command[key] = value;
21092
+ }
21093
+ if (body.operationExpiresAt !== void 0) {
21094
+ if (!Number.isSafeInteger(body.operationExpiresAt) || Number(body.operationExpiresAt) <= 0 || Number(body.operationExpiresAt) > Date.now() + 864e5) throw bad("Invalid retirement deadline");
21095
+ command.operationExpiresAt = Number(body.operationExpiresAt);
21096
+ }
21097
+ if (body.replacement !== void 0) {
21098
+ const replacement = body.replacement;
21099
+ if (!replacement || typeof replacement !== "object" || Object.keys(replacement).length !== 3 || !["workspaceId", "accountId", "nonce"].every((key) => typeof replacement[key] === "string" && /^[A-Za-z0-9_-]{43}$/.test(replacement[key]))) throw bad("Invalid replacement challenge");
21100
+ command.replacement = Object.fromEntries(["workspaceId", "accountId", "nonce"].map((key) => [key, replacement[key]]));
21101
+ }
21102
+ if (!(await client.listContacts()).contacts.some((contact) => contact.container_id.toUpperCase() === serverCid)) throw bad("Original enrollment contact is not ready");
21103
+ const outcome = await client.sendCommand({ contact: serverCid, command: "unregister-workspace", arguments: command });
21104
+ if (!("sent" in outcome) || !outcome.sent) throw bad("Workspace retirement was not sent");
21105
+ return { submitted: true, rootCid: identity.cid };
21029
21106
  },
21030
21107
  "POST /api/workspace/enroll": async ({ client, deps, body }) => {
21031
21108
  const identity = await client.currentIdentity();
@@ -21044,8 +21121,11 @@ var ROUTES = {
21044
21121
  const invitation = str(body, "invitation");
21045
21122
  if (invitation.length > 8192) throw bad("Workspace invitation too large");
21046
21123
  if (humanProfile) writeHumanProfile(deps.config.stateDir, deps.identityCid, humanProfile);
21047
- const peer = await client.addContact({ invite: invitation });
21048
- if (peer.cid.toUpperCase() !== serverCid) throw bad("Enrollment server identity mismatch");
21124
+ const connected = preserveProfile && (await client.listContacts()).contacts.some((contact) => contact.container_id.toUpperCase() === serverCid);
21125
+ if (!connected) {
21126
+ const peer = await client.addContact({ invite: invitation });
21127
+ if (peer.cid.toUpperCase() !== serverCid) throw bad("Enrollment server identity mismatch");
21128
+ }
21049
21129
  const contactDeadline = Date.now() + 1e4;
21050
21130
  while (!(await client.listContacts()).contacts.some((contact) => contact.container_id.toUpperCase() === serverCid)) {
21051
21131
  if (Date.now() >= contactDeadline) throw bad("Enrollment contact is not ready; obtain a fresh setup payload before retrying");
@@ -23656,7 +23736,8 @@ async function start(cfg, buildInfo, dependencies = DEFAULT_START_DEPENDENCIES)
23656
23736
  presenceSockets: presence.socketCount
23657
23737
  }),
23658
23738
  events,
23659
- identityCid: bound.cid
23739
+ identityCid: bound.cid,
23740
+ retireContactNotifications: notificationOutbox ? (cid) => notificationOutbox.retireContact(cid) : void 0
23660
23741
  };
23661
23742
  const moduleDir = dirname5(fileURLToPath(import.meta.url));
23662
23743
  const appCandidates = [
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  normalizeBasePath,
8
8
  operatorError,
9
9
  reportFailure
10
- } from "./chunks/chunk-S6BYVM5Q.js";
10
+ } from "./chunks/chunk-YU46BKJ2.js";
11
11
 
12
12
  // src/cli.ts
13
13
  init_define_MESSENGER_BUILD_INFO();
@@ -131,7 +131,7 @@ async function main() {
131
131
  }
132
132
  if (process.argv.length !== 3) throw new ConfigurationError("serve does not accept command-line options");
133
133
  const cfg = loadConfig();
134
- const { start } = await import("./chunks/server-EPHTMGZJ.js");
134
+ const { start } = await import("./chunks/server-XMJRG2WC.js");
135
135
  const handle = await start(cfg, BUILD_INFO);
136
136
  let closing = false;
137
137
  const shutdown = (signal) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/messenger-server",
3
- "version": "1.0.31-nightly.13",
3
+ "version": "1.0.31-nightly.15",
4
4
  "description": "Self-hosted Messenger REST/SSE backend for one identity on a shared Ours daemon.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
34
  "test:startup": "node --import tsx tests/startup-readiness.test.mjs",
35
35
  "test:runtime-catalog-candidate": "node --import tsx tests/runtime-command-catalog-reopen-e2e.test.mjs",
36
- "test:offline": "for f in tests/workspace-profile-preservation.test.mjs tests/notification-producer.test.mjs tests/base-path.test.mjs tests/build-epoch.test.mjs tests/owner-session.test.mjs tests/v1-runtime.test.mjs tests/typed-commands-core.test.mjs tests/shared-daemon-contract.test.mjs tests/room-envelope-matrix.test.mjs tests/bundle-smoke.test.mjs tests/build-metadata.test.mjs tests/production-security.test.mjs tests/conversation-page.test.mjs tests/media-response.test.mjs tests/html-preview-transform.test.mjs tests/html-preview-response.test.mjs tests/push-store.test.mjs tests/push-store-hardening.test.mjs tests/push-delivery.test.mjs tests/push-queue-saturation.test.mjs tests/events.test.mjs tests/watch-cursor.test.mjs tests/watch-reconcile.test.mjs tests/introduction-send-guard.test.mjs tests/sse-e2e.test.mjs tests/presence.test.mjs tests/static-hosting.test.mjs tests/room-preview.test.mjs; do echo \"# $f\"; node --import tsx \"$f\" || exit 1; done",
36
+ "test:offline": "for f in tests/workspace-profile-preservation.test.mjs tests/notification-producer.test.mjs tests/notification-contact-retirement.test.mjs tests/workspace-root-retirement.test.mjs tests/base-path.test.mjs tests/build-epoch.test.mjs tests/owner-session.test.mjs tests/v1-runtime.test.mjs tests/typed-commands-core.test.mjs tests/shared-daemon-contract.test.mjs tests/room-envelope-matrix.test.mjs tests/bundle-smoke.test.mjs tests/build-metadata.test.mjs tests/production-security.test.mjs tests/conversation-page.test.mjs tests/media-response.test.mjs tests/html-preview-transform.test.mjs tests/html-preview-response.test.mjs tests/push-store.test.mjs tests/push-store-hardening.test.mjs tests/push-delivery.test.mjs tests/push-queue-saturation.test.mjs tests/events.test.mjs tests/watch-cursor.test.mjs tests/watch-reconcile.test.mjs tests/introduction-send-guard.test.mjs tests/sse-e2e.test.mjs tests/presence.test.mjs tests/static-hosting.test.mjs tests/room-preview.test.mjs; do echo \"# $f\"; node --import tsx \"$f\" || exit 1; done",
37
37
  "test:loopback": "for f in tests/shared-daemon-e2e.test.mjs tests/typed-command-loopback-e2e.test.mjs tests/introduction-send.test.mjs tests/rest-e2e.test.mjs; do echo \"# $f\"; node --import tsx \"$f\" || exit 1; done",
38
38
  "test": "npm run build && npm run test:startup && npm run test:offline && npm run test:loopback"
39
39
  },