Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions compatibility/v1/extraction-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
88 changes: 38 additions & 50 deletions src/buzz.test.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,74 @@
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'

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<string, unknown>): Promise<string> {
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<Buzz>({ from: A, reason: 'Come home', timestamp: 42 })
expect(back).toEqual<Buzz>({ 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()
})
})
132 changes: 83 additions & 49 deletions src/buzz.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,115 @@
/**
* 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`.
*/

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<UnsignedEvent> {
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<Buzz> {
const plaintext = await decryptEnvelope(deriveGroupKey(seedHex), content)
let parsed: unknown
Expand All @@ -96,26 +118,38 @@ export async function decryptBuzz(seedHex: string, content: string): Promise<Buz
} catch {
throw new Error('Invalid buzz payload: not valid JSON')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Invalid buzz payload')
}
const o = parsed as Record<string, unknown>
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 }),
}
}
Loading