broapp 0.2.0 → 0.3.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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The execution gate.
3
+ *
4
+ * One place decides whether a call runs. Every path into an application's
5
+ * operations — a click in the browser, a model's tool call, an external agent
6
+ * over MCP, a workflow step — arrives here with an envelope saying who is
7
+ * asking, and leaves with a record saying what happened. There is no second
8
+ * door: an adapter that does not call `guard` is a bug, not a shortcut.
9
+ *
10
+ * The policy is deliberately three rows and no configuration language. What
11
+ * makes it trustworthy is not its expressiveness but where the inputs come
12
+ * from: the `channel` is set by the trusted adapter that received the request
13
+ * and never read out of model output, tool arguments or anything a browser
14
+ * sent. A model cannot claim to be the user, because nothing it can write is
15
+ * consulted when the channel is chosen.
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+
19
+ import type { Effect } from '../shared/contract.ts';
20
+ import { INTERNAL_ERROR_MESSAGE, isPublicError, publicError } from '../shared/errors.ts';
21
+
22
+ import type { HostLogger } from './app.ts';
23
+
24
+ /** Who is asking. Set by an adapter, never by the thing being adapted. */
25
+ export type Channel = 'user' | 'ai' | 'mcp' | 'workflow';
26
+ /** `preview` runs against a copy of the data, so nothing may leave the machine. */
27
+ export type ExecutionMode = 'live' | 'preview';
28
+ /** How the gate answered. */
29
+ export type Decision = 'allowed' | 'confirmed' | 'denied' | 'refused';
30
+ /** How the call itself ended, once it was allowed to start. */
31
+ export type Outcome = 'succeeded' | 'failed' | 'cancelled';
32
+ /** What the policy says about one (channel, effect, mode). */
33
+ export type PolicyVerdict = 'allow' | 'confirm' | 'refuse';
34
+
35
+ /**
36
+ * Who is asking, on behalf of what, and how the answer may be obtained.
37
+ *
38
+ * Built by the trusted adapter that received the request — the bridge
39
+ * handler, the AI runner, the MCP adapter, the workflow runner — and never
40
+ * from anything a model, a browser or an MCP client sent. That is the whole
41
+ * basis of the policy: a model cannot claim to be the user.
42
+ */
43
+ export interface Envelope {
44
+ /** Unique per request. Correlates the question, the answer and the record. */
45
+ readonly requestId: string;
46
+ readonly channel: Channel;
47
+ /** Free text for records: 'tab', 'ai:<runId>', 'mcp:<client>', 'workflow:<id>'. */
48
+ readonly caller: string;
49
+ /** May only tighten the gate's default: `preview` wins over `live`. */
50
+ readonly mode?: ExecutionMode;
51
+ readonly signal?: AbortSignal;
52
+ /** Who to ask when the policy says `confirm`. Absent means nobody, so denied. */
53
+ readonly approver?: Approver;
54
+ /**
55
+ * The effect an adapter resolved for a route the contract leaves silent.
56
+ *
57
+ * It fills a gap and never widens one: a route that declares an effect keeps
58
+ * it, whatever an adapter says. The AI layer's allow list is the reason this
59
+ * exists — an operation named under `read` there is a read tool even though
60
+ * an undeclared route is `write` everywhere else, and the gate has to be told
61
+ * the same thing the model was.
62
+ */
63
+ readonly effectHint?: Effect;
64
+ }
65
+
66
+ /** One decision to make: an envelope plus the route it is about. */
67
+ export interface GuardRequest extends Envelope {
68
+ readonly route: string;
69
+ readonly effect: Effect;
70
+ /** Already validated by the contract. Shown to the approver and hashed. */
71
+ readonly input: unknown;
72
+ }
73
+
74
+ /** What a person is asked, and what their answer has to name to count. */
75
+ export interface ApprovalQuestion {
76
+ readonly requestId: string;
77
+ readonly channel: Channel;
78
+ readonly caller: string;
79
+ readonly appId: string;
80
+ readonly releaseId: string;
81
+ readonly route: string;
82
+ readonly effect: Effect;
83
+ readonly input: unknown;
84
+ readonly argumentsHash: string;
85
+ /** When the gate started considering this call. */
86
+ readonly askedAt: number;
87
+ /**
88
+ * When an unanswered question stops waiting.
89
+ *
90
+ * Carried on the question rather than left to the asker so that every place
91
+ * a person is shown one — a strip in a tab, a card in a chat, an MCP
92
+ * client's own dialogue — can say how long they have without knowing which
93
+ * gate asked or how it was configured. A question with no visible deadline
94
+ * is one people answer after it has already been refused.
95
+ */
96
+ readonly expiresAt: number;
97
+ }
98
+
99
+ /** Somewhere a question can be put to a person. */
100
+ export interface Approver {
101
+ /**
102
+ * Ask a person. Resolves `true` only for an approval that names this
103
+ * question. Must resolve `false` when `signal` aborts.
104
+ */
105
+ ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean>;
106
+ }
107
+
108
+ /** What the gate writes down about one decision, whatever it was. */
109
+ export interface ExecutionRecord extends ApprovalQuestion {
110
+ readonly mode: ExecutionMode;
111
+ readonly decision: Decision;
112
+ readonly outcome?: Outcome;
113
+ /**
114
+ * What the call returned, present only when it succeeded.
115
+ *
116
+ * A recorder that keeps this can show a person what actually happened, and
117
+ * can draft a workflow from a run that worked. It is host-controlled output,
118
+ * not something a caller supplied — but a recorder that persists it is
119
+ * storing application data and owns whatever redaction that needs.
120
+ */
121
+ readonly output?: unknown;
122
+ /** A sentence safe to show. Never a stack, never a secret. */
123
+ readonly error?: string;
124
+ readonly startedAt: number;
125
+ readonly endedAt: number;
126
+ }
127
+
128
+ /** Where records go. A run store, a log, or nothing. */
129
+ export interface Recorder {
130
+ record(record: ExecutionRecord): void;
131
+ }
132
+
133
+ /** Options for {@link createGate}. */
134
+ export interface GateOptions {
135
+ readonly appId: string;
136
+ readonly releaseId: string;
137
+ readonly recorder?: Recorder;
138
+ /** Default 120_000. */
139
+ readonly confirmTimeoutMs?: number;
140
+ /** Default 'live'. A preview child passes 'preview'. */
141
+ readonly mode?: ExecutionMode;
142
+ readonly logger?: HostLogger;
143
+ }
144
+
145
+ /** The one door. */
146
+ export interface Gate {
147
+ readonly appId: string;
148
+ readonly releaseId: string;
149
+ readonly mode: ExecutionMode;
150
+ /** True while the gate is holding back everything that changes anything. */
151
+ readonly paused: boolean;
152
+ /**
153
+ * Stop admitting work that changes anything.
154
+ *
155
+ * For draining before an update: the application goes on answering questions
156
+ * while it is being replaced, and stops accepting new writes. `reason` is
157
+ * shown to whoever asked, so it should say what is happening rather than
158
+ * that something went wrong.
159
+ */
160
+ pause(reason: string): void;
161
+ /** Admit everything again. */
162
+ resume(): void;
163
+ /**
164
+ * Decide, ask if needed, record, run.
165
+ *
166
+ * Throws `PublicError` with code `rejected` when the policy refuses or a
167
+ * person declines, times out or the request is cancelled while waiting.
168
+ * `run` is called at most once and only after an allow or a confirmed
169
+ * approval.
170
+ */
171
+ guard<T>(request: GuardRequest, run: (signal: AbortSignal) => Promise<T>): Promise<T>;
172
+ }
173
+
174
+ /** How long a question waits when the gate is not told otherwise. */
175
+ const DEFAULT_CONFIRM_TIMEOUT_MS = 120_000;
176
+
177
+ /**
178
+ * The whole v1 policy. Pure, exported so the table can be tested row by row.
179
+ *
180
+ * Read for anybody, always: nothing changes, so there is nothing to approve.
181
+ * The owner's own click is allowed to write, because the owner is who the
182
+ * application belongs to. Every other channel is an agent acting on their
183
+ * behalf and asks first. `preview` refuses `external` outright for everyone:
184
+ * a preview runs against a copy of the data, and a copy of the data is not a
185
+ * copy of the world — a message sent from a preview is sent for real.
186
+ */
187
+ export function decide(channel: Channel, effect: Effect, mode: ExecutionMode): PolicyVerdict {
188
+ if (mode === 'preview' && effect === 'external') return 'refuse';
189
+ if (effect === 'read') return 'allow';
190
+ if (channel === 'user') return 'allow';
191
+ return 'confirm';
192
+ }
193
+
194
+ /**
195
+ * Canonical JSON (object keys sorted at every depth) hashed with sha256, hex,
196
+ * first 32 characters.
197
+ *
198
+ * Sorting is what makes the hash an identity rather than a formatting
199
+ * accident: the question a person was shown and the answer they gave have to
200
+ * be about the same arguments, and two encoders of the same object must not
201
+ * disagree about that.
202
+ */
203
+ export function argumentsHash(input: unknown): string {
204
+ return createHash('sha256').update(canonicalJson(input)).digest('hex').slice(0, 32);
205
+ }
206
+
207
+ /**
208
+ * JSON with every object's keys in sorted order, at every depth.
209
+ *
210
+ * Exported because more than one thing needs the same answer to "are these two
211
+ * values the same value": the gate, for an approval, and Autoapp's release
212
+ * identity, for a contract. Two sorters would eventually disagree.
213
+ */
214
+ export function canonicalJson(value: unknown): string {
215
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
216
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
217
+ const entries = Object.entries(value as Record<string, unknown>)
218
+ .filter(([, member]) => member !== undefined)
219
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
220
+ return `{${entries.map(([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`).join(',')}}`;
221
+ }
222
+
223
+ /** A signal that never aborts, for a request that brought none. */
224
+ function neverAborts(): AbortSignal {
225
+ return new AbortController().signal;
226
+ }
227
+
228
+ /** Build the gate one application runs behind. */
229
+ export function createGate(options: GateOptions): Gate {
230
+ const logger: HostLogger = options.logger ?? console;
231
+ const confirmTimeoutMs = options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
232
+ const gateMode: ExecutionMode = options.mode ?? 'live';
233
+ const recorder = options.recorder;
234
+ /** The reason writes are being held back, or `null` when they are not. */
235
+ let pausedReason: string | null = null;
236
+
237
+ /**
238
+ * Write one record.
239
+ *
240
+ * A recorder that throws is a broken diagnostic, not a reason to fail the
241
+ * user's call, so the throw stops here and is logged instead.
242
+ */
243
+ function write(record: ExecutionRecord): void {
244
+ if (recorder === undefined) return;
245
+ try {
246
+ recorder.record(record);
247
+ } catch (cause) {
248
+ logger.error(
249
+ `[broapp] the execution recorder failed for ${record.route}: ${String(cause instanceof Error ? (cause.stack ?? cause.message) : cause)}`,
250
+ );
251
+ }
252
+ }
253
+
254
+ const gate: Gate = {
255
+ appId: options.appId,
256
+ releaseId: options.releaseId,
257
+ mode: gateMode,
258
+
259
+ get paused() {
260
+ return pausedReason !== null;
261
+ },
262
+
263
+ pause(reason: string) {
264
+ pausedReason = reason;
265
+ },
266
+
267
+ resume() {
268
+ pausedReason = null;
269
+ },
270
+
271
+ async guard<T>(request: GuardRequest, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
272
+ // A pause is not a decision about this call — it is the application
273
+ // declining to be asked at all for a moment — so nothing is recorded and
274
+ // the code is `unavailable` rather than `rejected`. Reads still run: a
275
+ // draining application that stopped answering questions would look
276
+ // broken to whoever is still looking at it.
277
+ if (pausedReason !== null && request.effect !== 'read') {
278
+ throw publicError.unavailable(pausedReason);
279
+ }
280
+
281
+ // Either side may tighten to `preview`; neither may loosen back. A live
282
+ // gate asked to preview obeys, and a preview gate handed `live` in an
283
+ // envelope stays a preview.
284
+ const mode: ExecutionMode =
285
+ gateMode === 'preview' || request.mode === 'preview' ? 'preview' : 'live';
286
+ const at = Date.now();
287
+ const question: ApprovalQuestion = {
288
+ requestId: request.requestId,
289
+ channel: request.channel,
290
+ caller: request.caller,
291
+ appId: gate.appId,
292
+ releaseId: gate.releaseId,
293
+ route: request.route,
294
+ effect: request.effect,
295
+ input: request.input,
296
+ argumentsHash: argumentsHash(request.input),
297
+ // Filled in for every call, not only the ones that ask. A record of an
298
+ // allowed call carries the window it would have had, which costs two
299
+ // numbers and saves a reader working out whether one was even offered.
300
+ askedAt: at,
301
+ expiresAt: at + confirmTimeoutMs,
302
+ };
303
+
304
+ const verdict = decide(request.channel, request.effect, mode);
305
+ if (verdict === 'refuse') {
306
+ write({ ...question, mode, decision: 'refused', startedAt: at, endedAt: Date.now() });
307
+ throw publicError.rejected(`${request.route} is not allowed in preview`);
308
+ }
309
+
310
+ let decision: Decision = 'allowed';
311
+ if (verdict === 'confirm') {
312
+ const approver = request.approver;
313
+ if (approver === undefined) {
314
+ write({ ...question, mode, decision: 'denied', startedAt: at, endedAt: Date.now() });
315
+ throw publicError.rejected(`${request.route} needs approval and nobody can give it`);
316
+ }
317
+ // The question's own deadline is separate from the request's: a person
318
+ // who never answers must not hold a tool call open forever, and a
319
+ // cancelled request must stop asking.
320
+ const asking = new AbortController();
321
+ const timer = setTimeout(() => asking.abort(new Error('the question timed out')), confirmTimeoutMs);
322
+ const relay = (): void => asking.abort(new Error('the request was cancelled'));
323
+ request.signal?.addEventListener('abort', relay, { once: true });
324
+ if (request.signal?.aborted === true) relay();
325
+ let approved: boolean;
326
+ try {
327
+ approved = await approver.ask(question, asking.signal);
328
+ } finally {
329
+ clearTimeout(timer);
330
+ request.signal?.removeEventListener('abort', relay);
331
+ }
332
+ if (!approved) {
333
+ const cancelled = request.signal?.aborted === true;
334
+ write({
335
+ ...question,
336
+ mode,
337
+ decision: 'denied',
338
+ ...(cancelled ? { outcome: 'cancelled' as const } : {}),
339
+ startedAt: at,
340
+ endedAt: Date.now(),
341
+ });
342
+ throw publicError.rejected(`${request.route} was not approved`);
343
+ }
344
+ decision = 'confirmed';
345
+ }
346
+
347
+ const signal = request.signal ?? neverAborts();
348
+ const startedAt = Date.now();
349
+ try {
350
+ const value = await run(signal);
351
+ write({
352
+ ...question,
353
+ mode,
354
+ decision,
355
+ outcome: 'succeeded',
356
+ output: value,
357
+ startedAt,
358
+ endedAt: Date.now(),
359
+ });
360
+ return value;
361
+ } catch (cause) {
362
+ // A `PublicError` was written for whoever is watching and keeps its
363
+ // words. Anything else may name a path, a query or a token, so the
364
+ // record gets the same fixed sentence the browser would have seen.
365
+ write({
366
+ ...question,
367
+ mode,
368
+ decision,
369
+ outcome: signal.aborted ? 'cancelled' : 'failed',
370
+ error: isPublicError(cause) ? cause.message : INTERNAL_ERROR_MESSAGE,
371
+ startedAt,
372
+ endedAt: Date.now(),
373
+ });
374
+ throw cause;
375
+ }
376
+ },
377
+ };
378
+
379
+ return gate;
380
+ }
package/src/host/index.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  */
8
8
  export { createHostApp } from './app.ts';
9
9
  /**
10
- * Not for applications: it skips the check that refuses the reserved `ai`
11
- * route group. Only `broapp/ai/host` should use it.
10
+ * Not for applications: it skips the check that refuses Broapp's reserved
11
+ * route groups. Only `broapp/ai/host` and `broapp-autoapp/host` should use it.
12
12
  */
13
13
  export { createReservedHostApp } from './app.ts';
14
14
  export type {
@@ -21,6 +21,26 @@ export type {
21
21
  StreamSink,
22
22
  } from './app.ts';
23
23
 
24
+ export { argumentsHash, canonicalJson, createGate, decide } from './gate.ts';
25
+ export type {
26
+ Approver,
27
+ ApprovalQuestion,
28
+ Channel,
29
+ Decision,
30
+ Envelope,
31
+ ExecutionMode,
32
+ ExecutionRecord,
33
+ Gate,
34
+ GateOptions,
35
+ GuardRequest,
36
+ Outcome,
37
+ PolicyVerdict,
38
+ Recorder,
39
+ } from './gate.ts';
40
+
41
+ export { createPendingApprovals } from './approvals.ts';
42
+ export type { AnswerResult, ApprovalAnswer, PendingApprovals } from './approvals.ts';
43
+
24
44
  export { startApp } from './runtime.ts';
25
45
  export type { LifecycleMode, RunningApp, ShutdownReason, StartAppOptions } from './runtime.ts';
26
46
 
package/src/host/paths.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * it to the application.
10
10
  */
11
11
  import { homedir } from 'node:os';
12
- import { join } from 'node:path';
12
+ import { join, resolve } from 'node:path';
13
13
  import { mkdirSync } from 'node:fs';
14
14
 
15
15
  /** The environment variable that overrides the resolved directory. */
@@ -31,7 +31,11 @@ export const DATA_DIR_ENV = 'BROAPP_DATA_DIR';
31
31
  */
32
32
  export function dataDir(appName: string, env: NodeJS.ProcessEnv = process.env): string {
33
33
  const override = env[DATA_DIR_ENV];
34
- if (override !== undefined && override !== '') return override;
34
+ // Absolute, whatever was given. A relative override is convenient at a shell
35
+ // (`BROAPP_DATA_DIR=./run`), but paths under it are handed to child
36
+ // processes and to `import()`, and a relative path in an import specifier is
37
+ // a package name, not a file.
38
+ if (override !== undefined && override !== '') return resolve(override);
35
39
 
36
40
  const platform = process.platform;
37
41
  if (platform === 'win32') {
@@ -66,6 +66,16 @@ export interface RunningApp {
66
66
  readonly done: Promise<number>;
67
67
  /** Stop it. Safe to call more than once. */
68
68
  stop(reason?: ShutdownReason): Promise<void>;
69
+ /**
70
+ * True while at least one tab is actually connected.
71
+ *
72
+ * The same question the idle logic asks, exposed for a supervisor that has to
73
+ * report whether anybody is looking at this application. Deliberately not
74
+ * "a session exists": Brobridge retains a session for a minute after its
75
+ * socket drops so a reconnecting tab can resume, and a closed tab would go on
76
+ * looking attached for that whole minute.
77
+ */
78
+ readonly attached: boolean;
69
79
  }
70
80
 
71
81
  const POLL_INTERVAL_MS = 1_000;
@@ -159,12 +169,16 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
159
169
  });
160
170
  }
161
171
 
172
+ /** Whether any endpoint is open right now. One definition, two readers. */
173
+ const isAttached = (): boolean =>
174
+ bridge.sessions.some((session) => session.endpoint.state === 'open');
175
+
162
176
  if (mode === 'interactive') {
163
177
  const startedAt = Date.now();
164
178
  let idleSince: number | null = null;
165
179
 
166
180
  poll = setInterval(() => {
167
- const attached = bridge.sessions.some((session) => session.endpoint.state === 'open');
181
+ const attached = isAttached();
168
182
  const now = Date.now();
169
183
 
170
184
  if (attached) {
@@ -194,5 +208,12 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
194
208
  poll.unref?.();
195
209
  }
196
210
 
197
- return { bridge, done, stop };
211
+ return {
212
+ bridge,
213
+ done,
214
+ stop,
215
+ get attached() {
216
+ return isAttached();
217
+ },
218
+ };
198
219
  }
@@ -17,12 +17,28 @@
17
17
  */
18
18
  import type { Infer, Schema } from './schema.ts';
19
19
 
20
+ /**
21
+ * What an operation or stream does to the world.
22
+ *
23
+ * `read` changes nothing. `write` changes data inside the application's data
24
+ * directory. `external` reaches outside it: the network, other files, a
25
+ * spawned process, mail. The gate decides from this and from who is asking
26
+ * whether a call runs, waits for a person, or is refused. A route that does
27
+ * not say is treated as `write`, which asks a person before an agent may
28
+ * run it and lets the owner's own click through.
29
+ */
30
+ export type Effect = 'read' | 'write' | 'external';
31
+
32
+ /** The three strings an `effect` may be, for validation at definition time. */
33
+ const EFFECTS: readonly Effect[] = ['read', 'write', 'external'];
34
+
20
35
  /** One unary operation: JSON in, JSON out. */
21
36
  export interface OperationSpec<I = unknown, O = unknown> {
22
37
  readonly input: Schema<I>;
23
38
  readonly output: Schema<O>;
24
39
  /** Shown in generated documentation and in the developer panel. */
25
40
  readonly summary?: string;
41
+ readonly effect?: Effect;
26
42
  }
27
43
 
28
44
  /**
@@ -37,6 +53,12 @@ export interface StreamSpec<P = unknown, E = unknown> {
37
53
  readonly params: Schema<P>;
38
54
  readonly event: Schema<E>;
39
55
  readonly summary?: string;
56
+ readonly effect?: Effect;
57
+ }
58
+
59
+ /** The effect a route declares, or the conservative default. */
60
+ export function effectOf(spec: { readonly effect?: Effect }): Effect {
61
+ return spec.effect ?? 'write';
40
62
  }
41
63
 
42
64
  /** The operation and stream tables an application declares. */
@@ -123,6 +145,20 @@ export function defineContract<const C extends ContractShape>(shape: C): Contrac
123
145
  );
124
146
  }
125
147
  }
148
+ // An `effect` that is not one of the three words would silently become the
149
+ // conservative default at the gate, which reads as a working declaration
150
+ // while meaning nothing. A typo is refused where it was written instead.
151
+ for (const [route, spec] of [
152
+ ...Object.entries(shape.operations),
153
+ ...Object.entries(shape.streams),
154
+ ] as readonly (readonly [string, { readonly effect?: unknown }])[]) {
155
+ const effect = spec.effect;
156
+ if (effect !== undefined && !(EFFECTS as readonly unknown[]).includes(effect)) {
157
+ throw new TypeError(
158
+ `route ${JSON.stringify(route)} declares effect ${JSON.stringify(effect)}, which must be "read", "write" or "external"`,
159
+ );
160
+ }
161
+ }
126
162
  const clash = operations.find((route) => streams.includes(route));
127
163
  if (clash !== undefined) {
128
164
  throw new TypeError(`route ${JSON.stringify(clash)} is declared as both an operation and a stream`);
@@ -166,22 +202,29 @@ export function mergeContracts<A extends AnyContract, B extends AnyContract>(
166
202
  }>;
167
203
  }
168
204
 
169
- /** The route group Broapp reserves for its AI layer. */
170
- export const RESERVED_GROUPS: readonly string[] = ['ai'];
205
+ /**
206
+ * The route groups Broapp reserves for itself.
207
+ *
208
+ * `ai` belongs to the AI layer and `autoapp` to Autoapp's own host routes.
209
+ * Both are mounted as a second host app on the same bridge as the
210
+ * application's, so a name that appeared in both route tables would be
211
+ * unresolvable.
212
+ */
213
+ export const RESERVED_GROUPS: readonly string[] = ['ai', 'autoapp'];
171
214
 
172
215
  /**
173
216
  * Throws if a contract declares a route in a reserved group.
174
217
  *
175
- * This is not checked in `defineContract`, because Broapp's own AI contract is
176
- * built with `defineContract` and has to be allowed the group. It is checked
177
- * where an *application* contract enters the host instead.
218
+ * This is not checked in `defineContract`, because Broapp's own contracts are
219
+ * built with `defineContract` and have to be allowed their groups. It is
220
+ * checked where an *application* contract enters the host instead.
178
221
  */
179
222
  export function assertNoReservedRoutes(contract: AnyContract): void {
180
223
  for (const route of [...contract.routes.operations, ...contract.routes.streams]) {
181
224
  const { group } = splitRoute(route);
182
225
  if (RESERVED_GROUPS.includes(group)) {
183
226
  throw new TypeError(
184
- `route ${JSON.stringify(route)} uses the group ${JSON.stringify(group)}, which is reserved for Broapp's AI layer`,
227
+ `route ${JSON.stringify(route)} uses the group ${JSON.stringify(group)}, which is reserved for Broapp`,
185
228
  );
186
229
  }
187
230
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How long a person has left to answer a question.
3
+ *
4
+ * Shared rather than written twice because the same countdown appears wherever
5
+ * an approval is shown — an approvals strip in an application's tab, a confirm
6
+ * card in a chat panel — and two formatters would eventually disagree about
7
+ * what "one minute left" looks like. It is pure arithmetic over a timestamp:
8
+ * nothing here decides anything, and the gate's own timer is what actually
9
+ * refuses a question nobody answered.
10
+ */
11
+
12
+ /** Below this, a question is close enough to running out to say so loudly. */
13
+ export const URGENT_MS = 60_000;
14
+
15
+ /** Milliseconds until `expiresAt`, never negative. */
16
+ export function remainingMs(expiresAt: number, now: number = Date.now()): number {
17
+ return Math.max(0, expiresAt - now);
18
+ }
19
+
20
+ /**
21
+ * The time left as `m:ss`.
22
+ *
23
+ * Rounded up, so a question with 600 ms left reads `0:01` rather than `0:00`:
24
+ * a countdown that says zero while the button still works is a countdown
25
+ * people stop believing.
26
+ */
27
+ export function countdown(expiresAt: number, now: number = Date.now()): string {
28
+ const seconds = Math.ceil(remainingMs(expiresAt, now) / 1000);
29
+ return `${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`;
30
+ }
31
+
32
+ /** True while a question is nearly out of time, and false once it is out. */
33
+ export function isUrgent(expiresAt: number, now: number = Date.now()): boolean {
34
+ const left = remainingMs(expiresAt, now);
35
+ return left > 0 && left < URGENT_MS;
36
+ }