diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f22f5f..5850b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-07-19 + +Replaces free-text buzz reasons with a fixed, provider-defined coordination +vocabulary. Flock carries a tiny set of protocol actions rather than free-form +chat: senders choose an `action`, and receivers reject anything that is not a +known action rather than displaying arbitrary text. This narrows the service's +user-to-user content surface (see the app's OSA illegal-content assessment). + +### Added + +- `coordination` module — the complete human-to-human vocabulary as stable + actions with fixed labels: `check_in`, `on_my_way` (group) and `come_to_me`, + `where_are_you`, `call_me`, `on_my_way` (direct), plus `coordinationLabel`, + `coordinationActionFromLabel` (exact, never fuzzy), and + `isGroupCoordinationAction` / `isDirectCoordinationAction` guards. + +### Changed (breaking) + +- `buzz` — `buildBuzzSignal` now takes a provider-defined `action` + (`check_in` | `on_my_way` | `ring_lost_phone`) instead of a free-text + `reason`; `Buzz` gains a stable `action` field, and `reason` becomes a fixed + compatibility label derived from the action (never caller prose). +- `decryptBuzz` rejects any payload whose action is unknown, or whose + compatibility label does not exactly match its action — so arbitrary prose, + URLs and whitespace variants are dropped, not rendered. Older payloads that + carried only an exact known label still migrate. +- `DEFAULT_BUZZ_REASONS` is now derived from the group action labels + (`Check in`, `On my way`); free-text presets like `Come home` are removed. + ## [0.1.0] - 2026-07-18 Initial release of `@forgesworn/flock` as a standalone, framework-free diff --git a/compatibility/v1/extraction-manifest.json b/compatibility/v1/extraction-manifest.json index 0a593b4..14bdc74 100644 --- a/compatibility/v1/extraction-manifest.json +++ b/compatibility/v1/extraction-manifest.json @@ -3,8 +3,8 @@ "sourceRepository": "git@github.com:forgesworn/flock.git", "sourceCommit": "2b8c3a7512503b28c2fb1d25681a20e26728056d", "sourceGitTreeSha1": "b3be19dadb33449c5854891e60c5af4b7d36748d", - "sourceFileCount": 46, - "sourceSha256": "181b34abe31f0f365eaea8ce68ad6c642f64a8dc08422f7082dda6a2057f5aa3", + "sourceFileCount": 48, + "sourceSha256": "6bd339f9bbf9d4340e7eaacdd4f4b07f63332e894622ad32e9e0dd31c73eb347", "vectorFiles": [ "manifest.json", "radar-vectors.json", diff --git a/package.json b/package.json index c71c854..a1260ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@forgesworn/flock", - "version": "0.1.0", + "version": "0.2.0", "description": "Framework-free location-safety and trusted-circle protocol primitives for ForgeSworn applications.", "homepage": "https://github.com/forgesworn/flock-kit#readme", "type": "module", diff --git a/src/buzz.test.ts b/src/buzz.test.ts index e4833af..1b13827 100644 --- a/src/buzz.test.ts +++ b/src/buzz.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest' +import { deriveGroupKey, encryptEnvelope } from 'canary-kit/sync' import { buildBuzzSignal, decryptBuzz, BUZZ_SIGNAL_TYPE, - DEFAULT_BUZZ_REASONS, type Buzz, } from './buzz.js' @@ -11,76 +11,64 @@ const SEED = '0000000000000000000000000000000000000000000000000000000000000001' const A = 'a'.repeat(64) const B = 'b'.repeat(64) -describe('DEFAULT_BUZZ_REASONS', () => { - it('provides some presets', () => { - expect(DEFAULT_BUZZ_REASONS.length).toBeGreaterThan(0) - expect(DEFAULT_BUZZ_REASONS).toContain('Come home') - }) -}) +async function encrypted(payload: Record): Promise { + return encryptEnvelope(deriveGroupKey(SEED), JSON.stringify(payload)) +} -describe('buildBuzzSignal / decryptBuzz', () => { - it('round-trips a broadcast buzz', async () => { - const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'Come home', timestamp: 42 }) +describe('buildBuzzSignal / decryptBuzz — structured group signals', () => { + it('round-trips a provider-defined group action', async () => { + const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'on_my_way', timestamp: 42 }) expect(event.kind).toBe(20_078) expect(event.tags.find((t) => t[0] === 't')?.[1]).toBe(BUZZ_SIGNAL_TYPE) const back = await decryptBuzz(SEED, event.content) - expect(back).toEqual({ from: A, reason: 'Come home', timestamp: 42 }) + expect(back).toEqual({ from: A, action: 'on_my_way', reason: 'On my way', timestamp: 42 }) }) - it('round-trips a targeted buzz (parent → child)', async () => { - const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'Dinner', target: B, timestamp: 7 }) + it('turns Check in into a location ask without accepting a caller-defined ask', async () => { + const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'check_in', timestamp: 9 }) const back = await decryptBuzz(SEED, event.content) - expect(back.target).toBe(B) - expect(back.from).toBe(A) - expect(back.reason).toBe('Dinner') + expect(back).toMatchObject({ action: 'check_in', reason: 'Check in', ask: 'location' }) }) - it('trims the reason', async () => { - const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: ' Call me ' }) - expect((await decryptBuzz(SEED, event.content)).reason).toBe('Call me') + it('round-trips the one targeted system action used to ring a lost phone', async () => { + const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'ring_lost_phone', target: B, timestamp: 7 }) + expect(await decryptBuzz(SEED, event.content)).toMatchObject({ + from: A, + action: 'ring_lost_phone', + reason: '🔔 Ringing to find this phone', + target: B, + }) }) - it('a wrong seed cannot decrypt', async () => { - const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'Come home' }) - await expect(decryptBuzz('f'.repeat(64), event.content)).rejects.toThrow() + it('rejects targeting an ordinary group action and requires a target for ring', async () => { + await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'on_my_way', target: B })).rejects.toThrow() + await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'ring_lost_phone' })).rejects.toThrow() }) - it('rejects an empty reason', async () => { - await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: ' ' })).rejects.toThrow() - }) + it('accepts only exact known legacy shortcut labels', async () => { + const known = await encrypted({ from: A, reason: 'On my way', timestamp: 1 }) + expect(await decryptBuzz(SEED, known)).toMatchObject({ action: 'on_my_way', reason: 'On my way' }) - it('rejects a malformed sender', async () => { - await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: 'nope', reason: 'hi' })).rejects.toThrow() - }) + const arbitrary = await encrypted({ from: A, reason: 'meet behind the station', timestamp: 1 }) + await expect(decryptBuzz(SEED, arbitrary)).rejects.toThrow(/action/i) - it('rejects a malformed target', async () => { - await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'hi', target: 'nope' })).rejects.toThrow() + const link = await encrypted({ from: A, reason: 'https://example.com', timestamp: 1 }) + await expect(decryptBuzz(SEED, link)).rejects.toThrow(/action/i) }) - it('round-trips a location roll-call ask (check-in)', async () => { - const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'Check in', ask: 'location', timestamp: 9 }) - const back = await decryptBuzz(SEED, event.content) - expect(back.ask).toBe('location') - expect(back.reason).toBe('Check in') - }) - - it('a plain buzz carries no ask', async () => { - const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'Come home' }) - expect((await decryptBuzz(SEED, event.content)).ask).toBeUndefined() + it('rejects a mismatched compatibility label instead of displaying it', async () => { + const content = await encrypted({ from: A, action: 'on_my_way', reason: 'anything I want', timestamp: 1 }) + await expect(decryptBuzz(SEED, content)).rejects.toThrow(/label/i) }) - it('rejects an unknown ask on build', async () => { - await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, reason: 'hi', ask: 'battery' as never })).rejects.toThrow() + it('rejects malformed senders and targets', async () => { + await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: 'nope', action: 'on_my_way' })).rejects.toThrow() + await expect(buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'ring_lost_phone', target: 'nope' })).rejects.toThrow() }) - it('drops (never throws on) an unknown ask when decrypting — forwards-compatible', async () => { - // Hand-roll a payload a FUTURE client might send: today's client must keep - // the human-readable buzz and simply ignore the ask it doesn't know. - const { deriveGroupKey, encryptEnvelope } = await import('canary-kit/sync') - const content = await encryptEnvelope(deriveGroupKey(SEED), JSON.stringify({ from: A, reason: 'hi', timestamp: 1, ask: 'battery' })) - const back = await decryptBuzz(SEED, content) - expect(back.reason).toBe('hi') - expect(back.ask).toBeUndefined() + it('a wrong seed cannot decrypt', async () => { + const event = await buildBuzzSignal({ groupId: 'g', seedHex: SEED, from: A, action: 'on_my_way' }) + await expect(decryptBuzz('f'.repeat(64), event.content)).rejects.toThrow() }) }) diff --git a/src/buzz.ts b/src/buzz.ts index 66bdb50..7b62079 100644 --- a/src/buzz.ts +++ b/src/buzz.ts @@ -1,10 +1,10 @@ /** - * Buzz — a one-tap ping to the circle with a chosen meaning. + * Group coordination signals. * - * The friendly counterpart to `help`: a parent buzzes a child "come home", or - * any member nudges the group. A buzz carries a free-text `reason` (preset or - * custom — adults can assign their own) and an optional `target` member it's - * aimed at (others still see it, the target's phone buzzes hardest). + * Flock deliberately carries a tiny action vocabulary rather than free-form + * chat. The fixed `reason` remains in the encrypted payload only so an older + * client can display a signal sent by a current client; current clients derive + * meaning from `action` and reject any non-provider-defined text. * * Encrypted with the group envelope key (`deriveGroupKey`), carried as a * kind-20078 signal with `t=buzz`. @@ -12,82 +12,104 @@ import { buildSignalEvent, type UnsignedEvent } from 'canary-kit/nostr' import { deriveGroupKey, encryptEnvelope, decryptEnvelope } from 'canary-kit/sync' +import { + GROUP_COORDINATION_ACTIONS, + coordinationActionFromLabel, + coordinationLabel, + isGroupCoordinationAction, + type GroupCoordinationAction, +} from './coordination.js' /** The `t`-tag value for buzz signals. */ export const BUZZ_SIGNAL_TYPE = 'buzz' -/** Sensible default reasons; a circle can add its own. */ -export const DEFAULT_BUZZ_REASONS = [ - 'Come home', - 'Check in', - 'Where are you?', - 'Call me', - 'On my way', -] as const +/** Fixed labels retained for consumers that previously rendered this export. */ +export const DEFAULT_BUZZ_REASONS = GROUP_COORDINATION_ACTIONS.map(coordinationLabel) + +export const RING_LOST_PHONE_ACTION = 'ring_lost_phone' as const +export const RING_LOST_PHONE_LABEL = '🔔 Ringing to find this phone' as const +export type BuzzAction = GroupCoordinationAction | typeof RING_LOST_PHONE_ACTION const HEX_64_RE = /^[0-9a-f]{64}$/ -const MAX_REASON = 280 -/** A decrypted buzz. */ +/** A decrypted, provider-defined group signal. */ export interface Buzz { /** Sender pubkey (64-char hex). */ from: string - /** Free-text reason (preset or custom). */ + /** Stable protocol action. */ + action: BuzzAction + /** Fixed compatibility label; never caller-provided prose. */ reason: string - /** Optional recipient the buzz is aimed at (64-char hex); absent = whole circle. */ + /** Present only for the lost-phone ring action. */ target?: string /** Unix seconds. */ timestamp: number /** - * Optional ask riding the buzz. `'location'` = a roll-call: the sender is - * asking members to report where they are. Receivers decide FOR THEMSELVES - * how (or whether) to answer — an ask is never an automatic disclosure. - * Older clients ignore the field and show the buzz text as normal. + * `'location'` rides only a Check in: it asks members to report where they + * are. Receivers decide FOR THEMSELVES how (or whether) to answer — an ask is + * never an automatic disclosure. */ ask?: 'location' } -function validateReason(reason: string): string { - const r = (reason ?? '').trim() - if (!r) throw new Error('buzz reason must be a non-empty string') - if (r.length > MAX_REASON) throw new Error(`buzz reason must be at most ${MAX_REASON} characters`) - return r +function labelFor(action: BuzzAction): string { + return action === RING_LOST_PHONE_ACTION ? RING_LOST_PHONE_LABEL : coordinationLabel(action) +} + +/** + * Resolve the wire payload to a known action, or `null` if it is not one. + * + * Current payloads carry an explicit `action`. Older payloads carried only a + * fixed `reason` label, so an exact (never fuzzy) label lookup migrates them. + * Anything else — arbitrary prose, a URL, a stray whitespace variant — is not a + * provider action and is rejected rather than displayed. + */ +function parseBuzzAction(value: unknown, compatibilityLabel: unknown): BuzzAction | null { + if (value === RING_LOST_PHONE_ACTION || isGroupCoordinationAction(value)) return value + if (value !== undefined) return null + if (compatibilityLabel === RING_LOST_PHONE_LABEL) return RING_LOST_PHONE_ACTION + const legacy = coordinationActionFromLabel(compatibilityLabel) + return isGroupCoordinationAction(legacy) ? legacy : null } /** - * Build an unsigned kind-20078 buzz signal, encrypted with the group envelope key. + * Build an unsigned kind-20078 group signal, encrypted with the group envelope key. * - * @throws {Error} If `from`/`target` are not valid hex pubkeys or `reason` is empty/too long. + * @throws {Error} If `from`/`target` are not valid hex pubkeys, or `action` is + * not a provider-defined group action (or `ring_lost_phone` with a target). */ export async function buildBuzzSignal(params: { groupId: string seedHex: string from: string - reason: string + action: BuzzAction target?: string timestamp?: number - ask?: 'location' }): Promise { if (!HEX_64_RE.test(params.from)) throw new Error('from must be a 64-character lowercase hex pubkey') - if (params.target !== undefined && !HEX_64_RE.test(params.target)) { - throw new Error('target must be a 64-character lowercase hex pubkey') + if (params.action === RING_LOST_PHONE_ACTION) { + if (params.target === undefined || !HEX_64_RE.test(params.target)) { + throw new Error('ring_lost_phone requires a valid target pubkey') + } + } else if (!isGroupCoordinationAction(params.action)) { + throw new Error('unknown group action') + } else if (params.target !== undefined) { + throw new Error('ordinary group actions cannot target one member') } - if (params.ask !== undefined && params.ask !== 'location') { - throw new Error("ask must be 'location' when present") - } - const reason = validateReason(params.reason) + const payload: Buzz = { from: params.from, - reason, + action: params.action, + reason: labelFor(params.action), timestamp: params.timestamp ?? Math.floor(Date.now() / 1000), ...(params.target !== undefined && { target: params.target }), - ...(params.ask !== undefined && { ask: params.ask }), + ...(params.action === 'check_in' && { ask: 'location' as const }), } const encryptedContent = await encryptEnvelope(deriveGroupKey(params.seedHex), JSON.stringify(payload)) return buildSignalEvent({ groupId: params.groupId, signalType: BUZZ_SIGNAL_TYPE, encryptedContent }) } -/** Decrypt a buzz signal's content with the group envelope key. */ +/** Decrypt and validate a group signal, including exact-label legacy migration. */ export async function decryptBuzz(seedHex: string, content: string): Promise { const plaintext = await decryptEnvelope(deriveGroupKey(seedHex), content) let parsed: unknown @@ -96,26 +118,38 @@ export async function decryptBuzz(seedHex: string, content: string): Promise if (typeof o.from !== 'string' || !HEX_64_RE.test(o.from)) { throw new Error('Invalid buzz: from must be a 64-character lowercase hex pubkey') } - if (typeof o.reason !== 'string' || o.reason.trim().length === 0 || o.reason.length > MAX_REASON) { - throw new Error('Invalid buzz: reason missing or malformed') - } if (typeof o.timestamp !== 'number' || !Number.isFinite(o.timestamp)) { throw new Error('Invalid buzz: timestamp must be a number') } - if (o.target !== undefined && (typeof o.target !== 'string' || !HEX_64_RE.test(o.target))) { - throw new Error('Invalid buzz: target must be a 64-character lowercase hex pubkey') + + const action = parseBuzzAction(o.action, o.reason) + if (!action) throw new Error('Invalid buzz: unknown action') + const expectedLabel = labelFor(action) + if (o.reason !== expectedLabel) throw new Error('Invalid buzz: compatibility label does not match action') + + if (action === RING_LOST_PHONE_ACTION) { + if (typeof o.target !== 'string' || !HEX_64_RE.test(o.target)) { + throw new Error('Invalid buzz: ring target must be a 64-character lowercase hex pubkey') + } + if (o.ask !== undefined) throw new Error('Invalid buzz: ring cannot carry an ask') + return { from: o.from, action, reason: expectedLabel, target: o.target, timestamp: o.timestamp } } + + if (o.target !== undefined) throw new Error('Invalid buzz: ordinary group action cannot carry a target') + if (o.ask !== undefined && o.ask !== 'location') throw new Error('Invalid buzz: unknown ask') + if (action !== 'check_in' && o.ask !== undefined) throw new Error('Invalid buzz: only Check in can ask for location') return { from: o.from, - reason: o.reason, + action, + reason: expectedLabel, timestamp: o.timestamp, - ...(typeof o.target === 'string' && { target: o.target }), - // Unknown ask values are DROPPED, not fatal — a future ask kind must not - // make today's client throw away the human-readable buzz that carries it. - ...(o.ask === 'location' && { ask: 'location' as const }), + ...(action === 'check_in' && { ask: 'location' as const }), } } diff --git a/src/coordination.test.ts b/src/coordination.test.ts new file mode 100644 index 0000000..136efc9 --- /dev/null +++ b/src/coordination.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + COORDINATION_LABELS, + DIRECT_COORDINATION_ACTIONS, + GROUP_COORDINATION_ACTIONS, + coordinationActionFromLabel, + coordinationLabel, + isDirectCoordinationAction, + isGroupCoordinationAction, +} from './coordination.js' + +describe('structured coordination actions', () => { + it('keeps the group vocabulary deliberately small', () => { + expect(GROUP_COORDINATION_ACTIONS).toEqual(['check_in', 'on_my_way']) + expect(GROUP_COORDINATION_ACTIONS.map(coordinationLabel)).toEqual(['Check in', 'On my way']) + }) + + it('keeps private actions fixed, including consented Come to me', () => { + expect(DIRECT_COORDINATION_ACTIONS).toEqual(['come_to_me', 'where_are_you', 'call_me', 'on_my_way']) + expect(DIRECT_COORDINATION_ACTIONS.map(coordinationLabel)).toEqual([ + 'Come to me', + 'Where are you?', + 'Call me', + 'On my way', + ]) + }) + + it('maps only exact provider-defined labels back to actions', () => { + for (const [action, label] of Object.entries(COORDINATION_LABELS)) { + expect(coordinationActionFromLabel(label)).toBe(action) + } + expect(coordinationActionFromLabel('meet at the corner')).toBeNull() + expect(coordinationActionFromLabel('https://example.com')).toBeNull() + expect(coordinationActionFromLabel(' On my way ')).toBeNull() + }) + + it('separates group and private action sets', () => { + expect(isGroupCoordinationAction('check_in')).toBe(true) + expect(isGroupCoordinationAction('where_are_you')).toBe(false) + expect(isDirectCoordinationAction('where_are_you')).toBe(true) + expect(isDirectCoordinationAction('check_in')).toBe(false) + expect(isDirectCoordinationAction('anything_else')).toBe(false) + }) +}) diff --git a/src/coordination.ts b/src/coordination.ts new file mode 100644 index 0000000..d666ea2 --- /dev/null +++ b/src/coordination.ts @@ -0,0 +1,48 @@ +/** + * The complete human-to-human vocabulary Flock carries. + * + * These are protocol actions, not caller-provided messages. Labels are rendered + * locally and retained on the wire only as a compatibility field for older + * clients. Exact reverse mapping lets current clients migrate old fixed-label + * payloads without accepting arbitrary text. + */ +export const COORDINATION_LABELS = { + check_in: 'Check in', + on_my_way: 'On my way', + where_are_you: 'Where are you?', + call_me: 'Call me', + come_to_me: 'Come to me', +} as const + +export type CoordinationAction = keyof typeof COORDINATION_LABELS +export type CoordinationLabel = (typeof COORDINATION_LABELS)[CoordinationAction] + +export const GROUP_COORDINATION_ACTIONS = ['check_in', 'on_my_way'] as const +export type GroupCoordinationAction = (typeof GROUP_COORDINATION_ACTIONS)[number] + +export const DIRECT_COORDINATION_ACTIONS = ['come_to_me', 'where_are_you', 'call_me', 'on_my_way'] as const +export type DirectCoordinationAction = (typeof DIRECT_COORDINATION_ACTIONS)[number] + +const GROUP_ACTION_SET: ReadonlySet = new Set(GROUP_COORDINATION_ACTIONS) +const DIRECT_ACTION_SET: ReadonlySet = new Set(DIRECT_COORDINATION_ACTIONS) + +export function coordinationLabel(action: CoordinationAction): CoordinationLabel { + return COORDINATION_LABELS[action] +} + +/** Exact by design: whitespace, URLs, and caller-defined prose are not actions. */ +export function coordinationActionFromLabel(label: unknown): CoordinationAction | null { + if (typeof label !== 'string') return null + for (const action of Object.keys(COORDINATION_LABELS) as CoordinationAction[]) { + if (COORDINATION_LABELS[action] === label) return action + } + return null +} + +export function isGroupCoordinationAction(value: unknown): value is GroupCoordinationAction { + return typeof value === 'string' && GROUP_ACTION_SET.has(value) +} + +export function isDirectCoordinationAction(value: unknown): value is DirectCoordinationAction { + return typeof value === 'string' && DIRECT_ACTION_SET.has(value) +} diff --git a/src/index.ts b/src/index.ts index 902ee2f..f9001f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from 'canary-kit' export * from 'canary-kit/nostr' // --- flock additions --- +export * from './coordination.js' export * from './geofence.js' export * from './noreport.js' export * from './policy.js'