Skip to content

Reference source · 0.1.0-dev.11

notification-receiver.ts

src/notification-receiver.ts · Download original file

import { DatabaseSync } from 'node:sqlite';
import type { Identity, Credential } from './identity.ts';
import { TrustStore } from './identity.ts';
import { open, seal } from './envelope.ts';
import type { Envelope } from './envelope.ts';
import { callbackURL } from './callback-transport.ts';
import { exact, fingerprint, id, integer, jsonBytes, requireThat, unb64, parseObject } from './core.ts';

// Receiver is explicitly pinned to one Host and callback. A dedicated instance/database is
// recommended per endpoint. No clinical retrieval or external side effect runs in handle().
export class NotificationReceiver {
  db: DatabaseSync; identity: Identity; credential: Credential; hostCredential: Credential; trust: TrustStore; callback: string;
  constructor(path: string, identity: Identity, credential: Credential, hostCredential: Credential, trust: TrustStore, callback: string) {
    this.db = new DatabaseSync(path); this.identity = identity; this.credential = credential; this.hostCredential = hostCredential; this.trust = trust; this.callback = callbackURL(callback).href;
    this.db.exec(`PRAGMA journal_mode=WAL;
      CREATE TABLE IF NOT EXISTS receiver_owner(id INTEGER PRIMARY KEY CHECK(id=1), digest TEXT NOT NULL);
      CREATE TABLE IF NOT EXISTS expected_subscriptions(id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL);
      CREATE TABLE IF NOT EXISTS callback_subscriptions(id TEXT PRIMARY KEY, challenge_digest TEXT NOT NULL, expires_at INTEGER NOT NULL);
      CREATE TABLE IF NOT EXISTS callback_receipts(delivery_id TEXT PRIMARY KEY, subscription_id TEXT NOT NULL, digest TEXT NOT NULL);
      CREATE TABLE IF NOT EXISTS notification_inbox(subscription_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, pending INTEGER NOT NULL);`);
    const host = parseObject(unb64(hostCredential.payload));
    const owner = fingerprint(jsonBytes([identity.public.subject, identity.public.signingKid, identity.public.encryptionKid, host.subject, host.signingKid, host.encryptionKid, this.callback]));
    const previous = this.db.prepare('SELECT digest FROM receiver_owner WHERE id=1').get() as any;
    if (previous && previous.digest !== owner) { this.db.close(); requireThat(false, 'receiver_owner_changed'); }
    this.db.prepare('INSERT INTO receiver_owner VALUES (1,?) ON CONFLICT(id) DO NOTHING').run(owner);
  }
  // Application records its own expected registration BEFORE asking the Host. This prevents
  // an otherwise trusted Host from allocating arbitrary unsolicited receiver subscriptions.
  expect(subscriptionId: string, expiresAt: number): void {
    id(subscriptionId); integer(expiresAt);
    const previous = this.db.prepare('SELECT expires_at FROM expected_subscriptions WHERE id=?').get(subscriptionId) as any;
    requireThat(!previous || previous.expires_at === expiresAt, 'subscription_id_conflict');
    this.db.prepare('INSERT INTO expected_subscriptions VALUES (?,?) ON CONFLICT(id) DO NOTHING').run(subscriptionId, expiresAt);
  }
  handle(envelope: Envelope, now: number): Envelope {
    const host = this.trust.validate(this.hostCredential, now, undefined, 'host');
    this.trust.validate(this.credential, now, this.identity.public.subject, 'application');
    const opened = open(envelope, this.identity, this.trust, now, host.subject, null), body = opened.body;
    requireThat(opened.sender.kind === 'host' && opened.sender.signingKid === host.signingKid && opened.sender.encryptionKid === host.encryptionKid, 'wrong_host_key');
    id(body.subscription_id);
    const expected = this.db.prepare('SELECT expires_at FROM expected_subscriptions WHERE id=?').get(body.subscription_id) as any;
    requireThat(expected && now < expected.expires_at, 'unexpected_subscription');
    let reply: Record<string, any>;
    this.db.exec('BEGIN IMMEDIATE');
    try {
      if (body.operation === 'subscription_challenge') {
        exact(body, ['operation','subscription_id','challenge','callback','expires_at']);
        integer(body.expires_at); unb64(body.challenge, 32);
        requireThat(body.callback === this.callback && now < body.expires_at && body.expires_at <= Math.min(now + 300, expected.expires_at), 'invalid_callback_challenge');
        const digest = fingerprint(jsonBytes([body.operation, body.subscription_id, body.challenge, body.callback, body.expires_at])), prior = this.db.prepare('SELECT challenge_digest FROM callback_subscriptions WHERE id=?').get(body.subscription_id) as any;
        requireThat(!prior || prior.challenge_digest === digest, 'subscription_id_conflict');
        this.db.prepare('INSERT INTO callback_subscriptions VALUES (?,?,?) ON CONFLICT(id) DO NOTHING').run(body.subscription_id, digest, expected.expires_at);
        reply = { status: 'ok', operation: 'subscription_challenge_ack', subscription_id: body.subscription_id, challenge: body.challenge, callback: body.callback };
      } else {
        exact(body, ['operation','subscription_id','delivery_id']); requireThat(body.operation === 'change_notification', 'unsupported_operation'); id(body.delivery_id);
        requireThat(this.db.prepare('SELECT id FROM callback_subscriptions WHERE id=?').get(body.subscription_id), 'unverified_subscription');
        const digest = fingerprint(jsonBytes([body.operation, body.subscription_id, body.delivery_id])), prior = this.db.prepare('SELECT digest FROM callback_receipts WHERE delivery_id=?').get(body.delivery_id) as any;
        requireThat(!prior || prior.digest === digest, 'delivery_id_conflict');
        if (!prior) {
          this.db.prepare('INSERT INTO callback_receipts VALUES (?,?,?)').run(body.delivery_id, body.subscription_id, digest);
          this.db.prepare('INSERT INTO notification_inbox VALUES (?,1,1) ON CONFLICT(subscription_id) DO UPDATE SET revision=revision+1,pending=1').run(body.subscription_id);
        }
        reply = { status: 'ok', operation: 'notification_ack', subscription_id: body.subscription_id, delivery_id: body.delivery_id };
      }
      // Commit the receipt AND durable pull task before acknowledging. Duplicates get a fresh
      // reply envelope, preserving reply binding even when the first HTTP response was lost.
      this.db.exec('COMMIT');
    } catch (e) { this.db.exec('ROLLBACK'); throw e; }
    return seal(reply, this.identity, this.credential, opened.sender, now, opened.header.messageId);
  }
  pending(): { subscription_id: string; revision: number }[] {
    return this.db.prepare('SELECT subscription_id,revision FROM notification_inbox WHERE pending=1 ORDER BY subscription_id').all() as any;
  }
  // Call only after durably applying feed pages/cursor. An intervening notification increments
  // the revision, so finishing an earlier task cannot erase a newer wake-up. Keep the
  // counter after completion: resetting it would let a repeated old completion erase new work.
  complete(subscriptionId: string, revision: number): void {
    id(subscriptionId); integer(revision);
    this.db.prepare('UPDATE notification_inbox SET pending=0 WHERE subscription_id=? AND revision=?').run(subscriptionId, revision);
  }
  close(): void { this.db.close(); }
}