@agentshouse/app-bridge 0.1.0-cac36ee240fcd → 0.1.0-cbcf384385682

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
@@ -1,22 +1,25 @@
1
1
  # @agentshouse/app-bridge
2
2
 
3
- The renderer-side client of the agents.house App bridge. An App served by House
4
- receives one message port before its own scripts run; this package is that
5
- port's one listener. It carries eleven families: navigation, ceremonies,
6
- installation, notifications, status, Site image reads, App release code,
7
- external calls, App messages and App and Site screenshot reads.
3
+ The types of the agents.house App bridge, an accessor and an in-page test host.
4
+ House builds the bridge object in every App it serves and hands it to the page
5
+ before the App's own scripts run; `houseBridge()` returns that object, so an App
6
+ carries no bridge code of its own. It carries eleven families: navigation,
7
+ ceremonies, installation, notifications, status, Site image reads, App release
8
+ code, external calls, App messages and App and Site screenshot reads.
8
9
  [docs/families.md](docs/families.md) describes each and the test host.
9
10
 
10
- The package carries no credential handling and no Runtime family. House builds
11
+ The bridge carries no credential handling and no Runtime family. House builds
11
12
  the MD Runtime client into the page beside the bridge, and `bridge.runtime`
12
13
  resolves to that client, so the App imports and constructs none.
13
14
 
15
+ House's `find_command` "App" is the guide to building an App or a Site.
16
+
14
17
  ## Use
15
18
 
16
19
  ```js
17
- import { connectHouseBridge } from '@agentshouse/app-bridge';
20
+ import { houseBridge } from '@agentshouse/app-bridge';
18
21
 
19
- const bridge = connectHouseBridge();
22
+ const bridge = houseBridge();
20
23
  const md = await bridge.runtime;
21
24
 
22
25
  bridge.onAddress((address) => render(address));
@@ -32,22 +35,24 @@ async function manageSecurity(securityPath) {
32
35
  try {
33
36
  await bridge.ceremony(securityPath);
34
37
  } catch (refusal) {
35
- report(refusal);
38
+ if (refusal.name === 'HouseCeremonyRefused') report(refusal.path);
36
39
  }
37
40
  }
38
41
  ```
39
42
 
40
43
  `bridge.runtime` resolves to the `MdRuntimeClient` of
41
- `@agentshouse/mdruntime/client`, whose README describes its members.
44
+ `@agentshouse/mdruntime/client`, whose README describes its members. Its
45
+ `call(name, input)` reaches House: `HouseCalls` names each Call an App makes
46
+ and the input it takes, such as `call('member.list', { room_ref })`.
42
47
 
43
48
  ## Test without a House
44
49
 
45
50
  ```js
46
- import { connectHouseBridge } from '@agentshouse/app-bridge';
51
+ import { houseBridge } from '@agentshouse/app-bridge';
47
52
  import { createHouseBridgeTestHost } from '@agentshouse/app-bridge/test-host';
48
53
 
49
54
  const host = createHouseBridgeTestHost({ address: '/rooms/atlas' });
50
- const bridge = connectHouseBridge(host);
55
+ const bridge = houseBridge();
51
56
 
52
57
  bridge.push('/rooms/atlas/members');
53
58
  host.open('/rooms/atlas');
@@ -0,0 +1,117 @@
1
+ export interface HouseCalls {
2
+ readonly 'access.read': {
3
+ readonly room_ref: string;
4
+ };
5
+ readonly 'conversation.answer': {
6
+ readonly answer: {
7
+ readonly interaction_id: string;
8
+ readonly response: unknown;
9
+ };
10
+ readonly conversation: string;
11
+ readonly operation_id?: string;
12
+ };
13
+ readonly 'conversation.create': {
14
+ readonly agent_id: string;
15
+ readonly operation_id?: string;
16
+ };
17
+ readonly 'conversation.delete': {
18
+ readonly conversation: string;
19
+ readonly operation_id?: string;
20
+ };
21
+ readonly 'conversation.history': {
22
+ readonly conversations: readonly {
23
+ readonly after?: string;
24
+ readonly conversation: string;
25
+ }[];
26
+ };
27
+ readonly 'conversation.interrupt': {
28
+ readonly conversation: string;
29
+ readonly operation_id?: string;
30
+ };
31
+ readonly 'conversation.kill': {
32
+ readonly conversation: string;
33
+ readonly operation_id?: string;
34
+ };
35
+ readonly 'conversation.markDone': {
36
+ readonly conversation_id?: string;
37
+ readonly result?: string;
38
+ readonly operation_id: string;
39
+ };
40
+ readonly 'conversation.rename': {
41
+ readonly conversation_id: string;
42
+ readonly operation_id: string;
43
+ readonly name: string;
44
+ };
45
+ readonly 'conversation.send': {
46
+ readonly conversation: string;
47
+ readonly files?: readonly {
48
+ readonly blob: Blob;
49
+ readonly byteLength: number;
50
+ readonly contentHash: string;
51
+ readonly height?: number;
52
+ readonly mediaType: string;
53
+ readonly name: string;
54
+ readonly width?: number;
55
+ }[];
56
+ readonly operation_id?: string;
57
+ readonly text?: string;
58
+ };
59
+ readonly 'conversation.setOption': {
60
+ readonly conversation_id: string;
61
+ readonly operation_id: string;
62
+ readonly option: string;
63
+ readonly value: string | boolean;
64
+ };
65
+ readonly 'feature.list': Readonly<Record<string, never>>;
66
+ readonly 'holder.list': Readonly<Record<string, never>>;
67
+ readonly 'holder.ring': {
68
+ readonly to: string;
69
+ };
70
+ readonly 'member.list': {
71
+ readonly room_ref: string;
72
+ readonly history?: boolean;
73
+ readonly limit?: number;
74
+ readonly cursor?: string;
75
+ };
76
+ readonly 'message.send': {
77
+ readonly to: string;
78
+ readonly body: unknown;
79
+ };
80
+ readonly 'processing.read': {
81
+ readonly room_ref: string;
82
+ readonly path: string;
83
+ };
84
+ readonly 'record.list': {
85
+ readonly room_ref: string;
86
+ readonly record?: string;
87
+ readonly cursor?: string;
88
+ readonly limit?: number;
89
+ readonly order?: 'asc' | 'desc';
90
+ };
91
+ readonly 'relay.read': Readonly<Record<string, never>>;
92
+ readonly 'routine.list': {
93
+ readonly room_ref: string;
94
+ };
95
+ readonly 'routineAccess.list': {
96
+ readonly room_ref: string;
97
+ };
98
+ readonly 'routineAllowance.list': {
99
+ readonly room_ref: string;
100
+ readonly access: string;
101
+ };
102
+ readonly 'routineRequest.list': {
103
+ readonly room_ref: string;
104
+ };
105
+ readonly 'storage.read': {
106
+ readonly room_ref?: string;
107
+ };
108
+ readonly submit: {
109
+ readonly content: string;
110
+ };
111
+ readonly 'trigger.list': {
112
+ readonly room_ref: string;
113
+ };
114
+ readonly 'triggerOccurrence.list': {
115
+ readonly room_ref: string;
116
+ };
117
+ }
package/dist/calls.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,137 @@
1
+ import type { MdRuntimeClient } from '@agentshouse/mdruntime/client';
2
+ import type { HouseCalls } from './calls.js';
3
+ export type { HouseCalls } from './calls.js';
4
+ type Open<Value extends string> = Value | (string & {});
5
+ export type HouseCallInput<Name extends string> = Name extends keyof HouseCalls ? HouseCalls[Name] : unknown;
6
+ export interface HouseRuntime extends Omit<MdRuntimeClient, 'call'> {
7
+ call<Name extends Open<keyof HouseCalls>>(name: Name, input: HouseCallInput<Name>): ReturnType<MdRuntimeClient['call']>;
8
+ }
9
+ export interface HouseSyncState {
10
+ readonly state: string;
11
+ readonly providerUnreachable: boolean;
12
+ }
13
+ export interface HouseCredentialState {
14
+ readonly state: Open<'none' | 'held' | 'rejected'>;
15
+ readonly expiresAt: number | null;
16
+ }
17
+ export type HouseAnalyticsChoice = 'accept' | 'decline';
18
+ export interface HouseAnalytics {
19
+ readonly measures: boolean;
20
+ readonly choice: Open<HouseAnalyticsChoice> | null;
21
+ }
22
+ export interface HouseStatus {
23
+ readonly app: string | null;
24
+ readonly preferences: {
25
+ readonly roomRef: string;
26
+ readonly path: string;
27
+ } | null;
28
+ readonly runtime: string;
29
+ readonly credential: HouseCredentialState;
30
+ readonly sync: HouseSyncState | null;
31
+ readonly refusal: {
32
+ readonly code: string;
33
+ readonly retryable: boolean;
34
+ } | null;
35
+ readonly loss: string | null;
36
+ readonly analytics: HouseAnalytics;
37
+ readonly serviceMode: Open<'open' | 'write-halted'> | null;
38
+ readonly offline: boolean;
39
+ }
40
+ export type HouseNavigationMode = 'push' | 'replace';
41
+ export interface HouseTab {
42
+ readonly title?: string;
43
+ readonly icon?: string;
44
+ }
45
+ interface HouseRelease {
46
+ readonly app_ref: string;
47
+ readonly release: string;
48
+ }
49
+ export interface HouseSourceTop extends HouseRelease {
50
+ readonly folders: readonly string[];
51
+ readonly files: readonly {
52
+ readonly name: string;
53
+ readonly size: number;
54
+ }[];
55
+ readonly readme: string | null;
56
+ }
57
+ export type HouseSourceFile = {
58
+ readonly path: string;
59
+ readonly size: number;
60
+ } & ({
61
+ readonly text: string;
62
+ } | {
63
+ readonly base64: string;
64
+ });
65
+ export interface HouseSourceDownload extends HouseRelease {
66
+ readonly files: readonly HouseSourceFile[];
67
+ }
68
+ export interface HouseExternalRequest {
69
+ readonly url: string;
70
+ readonly method?: string;
71
+ readonly headers?: Readonly<Record<string, string>>;
72
+ readonly body?: string;
73
+ readonly body_base64?: string;
74
+ readonly account?: string;
75
+ readonly slot?: string;
76
+ }
77
+ export type HouseExternalAnswer = {
78
+ readonly status: number;
79
+ readonly headers: Readonly<Record<string, string | readonly string[]>>;
80
+ } & ({
81
+ readonly body: string;
82
+ } | {
83
+ readonly body_base64: string;
84
+ });
85
+ export interface HouseRefusal {
86
+ readonly error: {
87
+ readonly code: string;
88
+ readonly retryable: boolean;
89
+ };
90
+ }
91
+ export interface HouseAppMessage {
92
+ readonly from: string;
93
+ readonly body: unknown;
94
+ }
95
+ export type HouseInstallation = Open<'accepted' | 'dismissed' | 'manual'>;
96
+ export type HouseNotifications = Open<'on' | 'off' | 'blocked' | 'unavailable'>;
97
+ export interface HouseCeremonyRefused extends Error {
98
+ readonly name: 'HouseCeremonyRefused';
99
+ readonly code: Open<'house_ceremony_refused'>;
100
+ readonly retryable: boolean;
101
+ readonly path: string | null;
102
+ }
103
+ export interface HouseNavigationRefused extends Error {
104
+ readonly name: 'HouseNavigationRefused';
105
+ readonly code: Open<'house_navigation_refused'>;
106
+ readonly retryable: boolean;
107
+ readonly address: string | null;
108
+ }
109
+ export interface HouseBrowserRefused extends Error {
110
+ readonly code: string;
111
+ readonly retryable: boolean;
112
+ }
113
+ export interface HouseBridge {
114
+ readonly runtime: Promise<HouseRuntime>;
115
+ address(): string | null;
116
+ onAddress(listener: (address: string) => void): () => void;
117
+ push(address: string): Promise<string>;
118
+ replace(address: string): Promise<string>;
119
+ ceremony(path: string): Promise<never>;
120
+ install(): Promise<HouseInstallation>;
121
+ notifications(on?: boolean): Promise<HouseNotifications>;
122
+ setBadge(unread: readonly string[]): Promise<void>;
123
+ setTab(tab: HouseTab): void;
124
+ sourceTop(app: string, release?: string): Promise<HouseSourceTop | HouseRefusal | null>;
125
+ sourceDownload(app: string, release?: string): Promise<HouseSourceDownload | HouseRefusal | null>;
126
+ callExternal(request: HouseExternalRequest): Promise<HouseExternalAnswer | HouseRefusal | null>;
127
+ readScreenshot(app: string): Promise<Blob | null>;
128
+ readSiteScreenshot(site: string): Promise<Blob | null>;
129
+ status(): HouseStatus | null;
130
+ onStatus(listener: (status: HouseStatus) => void): () => void;
131
+ messages(listener: (message: HouseAppMessage) => void): () => void;
132
+ analyticsConsent(choice: HouseAnalyticsChoice): void;
133
+ }
134
+ export interface HouseHeld {
135
+ readonly houseBridge: HouseBridge;
136
+ }
137
+ export declare const houseBridge: () => HouseBridge;
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export const houseBridge = () => globalThis.houseBridge;
@@ -1,14 +1,13 @@
1
1
  import type { MdRuntimeClient } from '@agentshouse/mdruntime/client';
2
- import type { HouseBridgeSource, HouseImageCopy } from './bridge.js';
3
- import type { HouseAppMessage, HouseExternalAnswer, HouseExternalRequest, HouseGaChoice, HouseNavigationMode, HouseRefusal, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './wire.js';
2
+ import type { HouseAnalyticsChoice, HouseAppMessage, HouseExternalAnswer, HouseExternalRequest, HouseNavigationMode, HouseRefusal, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './index.js';
4
3
  export interface HouseBridgeNavigation {
5
4
  readonly mode: HouseNavigationMode;
6
5
  readonly address: string;
7
6
  }
8
- export interface HouseBridgeTestHost extends HouseBridgeSource {
7
+ export interface HouseBridgeTestHost {
9
8
  readonly navigations: readonly HouseBridgeNavigation[];
10
9
  readonly ceremonies: readonly string[];
11
- readonly consents: readonly HouseGaChoice[];
10
+ readonly consents: readonly HouseAnalyticsChoice[];
12
11
  readonly tabs: readonly HouseTab[];
13
12
  readonly externalCalls: readonly HouseExternalRequest[];
14
13
  address(): string;
@@ -20,9 +19,6 @@ export interface HouseBridgeTestHost extends HouseBridgeSource {
20
19
  export interface HouseBridgeTestHostOptions {
21
20
  readonly address?: string;
22
21
  readonly status?: HouseStatus;
23
- readonly images?: readonly (HouseImageCopy & {
24
- readonly room: string;
25
- })[];
26
22
  readonly sources?: readonly {
27
23
  readonly top: HouseSourceTop;
28
24
  readonly download: HouseSourceDownload;
@@ -0,0 +1,173 @@
1
+ const HOST_ORIGIN = 'https://house.invalid';
2
+ const ADDRESS_MAX_CHARS = 64 * 1024;
3
+ function sameOriginPath(value) {
4
+ if (typeof value !== 'string')
5
+ return null;
6
+ if (value.charAt(0) !== '/' || value.slice(0, 2) === '//')
7
+ return null;
8
+ if (value.length > ADDRESS_MAX_CHARS)
9
+ return null;
10
+ const target = new URL(value, HOST_ORIGIN);
11
+ return target.origin === HOST_ORIGIN ? `${target.pathname}${target.search}${target.hash}` : null;
12
+ }
13
+ function appAddress(address) {
14
+ return address.startsWith('/') ? address : `/${address}`;
15
+ }
16
+ function ceremonyRefused(path) {
17
+ return Object.assign(new Error('house_ceremony_refused'), {
18
+ name: 'HouseCeremonyRefused',
19
+ code: 'house_ceremony_refused',
20
+ retryable: false,
21
+ path,
22
+ });
23
+ }
24
+ function navigationRefused(address) {
25
+ return Object.assign(new Error('house_navigation_refused'), {
26
+ name: 'HouseNavigationRefused',
27
+ code: 'house_navigation_refused',
28
+ retryable: false,
29
+ address,
30
+ });
31
+ }
32
+ function followHeld(listeners, listener, held) {
33
+ let following = true;
34
+ queueMicrotask(() => {
35
+ if (!following)
36
+ return;
37
+ listeners.add(listener);
38
+ const value = held();
39
+ if (value !== null)
40
+ listener(value);
41
+ });
42
+ return () => {
43
+ following = false;
44
+ listeners.delete(listener);
45
+ };
46
+ }
47
+ export function createHouseBridgeTestHost(options = {}) {
48
+ const navigations = [];
49
+ const ceremonies = [];
50
+ const consents = [];
51
+ const tabs = [];
52
+ const externalCalls = [];
53
+ const addressListeners = new Set();
54
+ const statusListeners = new Set();
55
+ const messageListeners = new Set();
56
+ let current = appAddress(options.address ?? '/');
57
+ let published = options.status ?? null;
58
+ let shown = null;
59
+ let seen = null;
60
+ let attached = () => { };
61
+ const runtime = new Promise((resolve) => {
62
+ attached = resolve;
63
+ });
64
+ function arrive(target) {
65
+ current = target;
66
+ queueMicrotask(() => {
67
+ shown = target;
68
+ for (const listener of addressListeners)
69
+ listener(target);
70
+ });
71
+ }
72
+ function publish(next) {
73
+ published = next;
74
+ queueMicrotask(() => {
75
+ seen = next;
76
+ for (const listener of statusListeners)
77
+ listener(next);
78
+ });
79
+ }
80
+ function navigate(mode, asked) {
81
+ let path = asked;
82
+ if (/^https?:\/\//i.test(asked)) {
83
+ try {
84
+ const target = new URL(asked);
85
+ path = target.origin === HOST_ORIGIN ? target.pathname + target.search + target.hash : '';
86
+ }
87
+ catch {
88
+ path = '';
89
+ }
90
+ }
91
+ const target = sameOriginPath(path);
92
+ if (target === null)
93
+ return Promise.reject(navigationRefused(asked));
94
+ navigations.push({ mode, address: target });
95
+ arrive(target);
96
+ return new Promise((resolve) => {
97
+ queueMicrotask(() => resolve(target));
98
+ });
99
+ }
100
+ const bridge = {
101
+ runtime,
102
+ address: () => shown,
103
+ onAddress(listener) {
104
+ return followHeld(addressListeners, listener, () => shown);
105
+ },
106
+ push: (asked) => navigate('push', asked),
107
+ replace: (asked) => navigate('replace', asked),
108
+ ceremony(path) {
109
+ const target = sameOriginPath(path);
110
+ if (target === null)
111
+ return Promise.reject(ceremonyRefused(path));
112
+ ceremonies.push(target);
113
+ return new Promise(() => { });
114
+ },
115
+ install: () => Promise.resolve('manual'),
116
+ notifications: () => Promise.resolve('unavailable'),
117
+ setBadge: () => Promise.resolve(),
118
+ setTab(tab) {
119
+ tabs.push(tab);
120
+ },
121
+ sourceTop: (app) => Promise.resolve(options.sources?.find((entry) => entry.top.app_ref === app)?.top ?? null),
122
+ sourceDownload: (app) => Promise.resolve(options.sources?.find((entry) => entry.top.app_ref === app)?.download ?? null),
123
+ callExternal(request) {
124
+ externalCalls.push(request);
125
+ return Promise.resolve(options.external?.(request) ?? null);
126
+ },
127
+ readScreenshot: (app) => Promise.resolve(options.screenshots?.[app] ?? null),
128
+ readSiteScreenshot: (site) => Promise.resolve(options.siteScreenshots?.[site] ?? null),
129
+ status: () => seen,
130
+ onStatus(listener) {
131
+ return followHeld(statusListeners, listener, () => seen);
132
+ },
133
+ messages(listener) {
134
+ messageListeners.add(listener);
135
+ return () => {
136
+ messageListeners.delete(listener);
137
+ };
138
+ },
139
+ analyticsConsent(choice) {
140
+ if (published === null || !published.analytics.measures)
141
+ return;
142
+ if (choice !== 'accept' && choice !== 'decline')
143
+ return;
144
+ consents.push(choice);
145
+ publish({ ...published, analytics: { measures: true, choice } });
146
+ },
147
+ };
148
+ Object.defineProperty(globalThis, 'houseBridge', { value: bridge, configurable: true });
149
+ if (published !== null)
150
+ publish(published);
151
+ arrive(current);
152
+ return {
153
+ navigations,
154
+ ceremonies,
155
+ consents,
156
+ tabs,
157
+ externalCalls,
158
+ address: () => current,
159
+ open(asked) {
160
+ arrive(appAddress(asked));
161
+ },
162
+ publish,
163
+ message({ from, body }) {
164
+ queueMicrotask(() => {
165
+ for (const listener of messageListeners)
166
+ listener({ from, body });
167
+ });
168
+ },
169
+ attachRuntime(client) {
170
+ attached(client);
171
+ },
172
+ };
173
+ }
package/docs/families.md CHANGED
@@ -18,28 +18,32 @@
18
18
  `'accepted'` or `'dismissed'` with the User's choice, spending that prompt;
19
19
  otherwise, as on every iPhone and iPad, it resolves `'manual'` and the App
20
20
  shows its own instructions. A prompt the browser refuses to show, as for a
21
- call made without a click, rejects with the browser's own `DOMException`
22
- and stays ready. Call it from a click. The standard
21
+ call made without a click, rejects with an error named and coded by the
22
+ browser's own error name, with `retryable` false, and stays ready. Call it from a click. The standard
23
23
  `display-mode: standalone` media query tells the App it runs installed.
24
24
  - **Notifications** - House's own panel alone; an App's or a Site's call
25
- rejects with `HouseCeremonyRefused`. `notifications()` answers this browser
25
+ rejects with an error named `HouseCeremonyRefused`, carrying `code` and `retryable`. `notifications()` answers this browser
26
26
  storage's push state: `'on'`, `'off'`, `'blocked'` where the browser refuses
27
27
  notifications, or `'unavailable'` where it has no push, as on an iPhone or
28
28
  iPad outside a Home-screen app. `notifications(true)` asks the browser's
29
29
  permission and subscribes, `notifications(false)` unsubscribes, and each
30
- answers the state it leaves; a failure of the browser's push service rejects
31
- with the browser's own `DOMException`. Call `notifications(true)` from a
30
+ answers the state it leaves; a failure of the browser's push service, and
31
+ of `setBadge`, rejects the same way. Call `notifications(true)` from a
32
32
  click. `setBadge(unread)` takes the identifiers of the Human Notifications
33
33
  unread in this storage and sets the icon's badge to their number, or clears
34
34
  it at none, where the browser shows one.
35
- - **Status** - the App reference the runtime client is attached under, sync
36
- state, credential state and observed failure, `ga`:
35
+ - **Status** - the App reference the runtime client is attached under,
36
+ `preferences`: the `roomRef` and Path of the App's own preferences document,
37
+ or `null` until the runtime holds that Room, sync
38
+ state, credential state and its `expiresAt`, observed failure, `analytics`:
37
39
  whether the page measures and the visitor's stored analytics choice, and
38
- `service_mode`: `open`, `write-halted` while House refuses writes, so the
40
+ `serviceMode`: `open`, `write-halted` while House refuses writes, so the
39
41
  App can stop retrying them, or `null` while the runtime holds no House
40
- connection.
41
- The App reports the visitor's choice with `gaConsent('accept')` or
42
- `gaConsent('decline')`; the shell hands it to House's analytics loader, and
42
+ connection. Every status field is camelCase. `onStatus(listener)` and
43
+ `onAddress(listener)` hand a listener the status and the address the bridge
44
+ already holds, then each change.
45
+ The App reports the visitor's choice with `analyticsConsent('accept')` or
46
+ `analyticsConsent('decline')`; the shell hands it to House's analytics loader, and
43
47
  the choice never reaches House.
44
48
  - **Site image read** - given a Published Room and an original attachment
45
49
  reference, it answers the web copy's path and metadata, or `null` when no
@@ -51,14 +55,14 @@
51
55
  path, its size and its `text`, or its bytes as `base64`. The browser keeps
52
56
  each answer per App and release: a read naming a release it holds answers
53
57
  without asking House, and keeping a newer release drops the earlier one. A
54
- refusal is House's `{ error: { code } }`, `app_release_not_found` for an
58
+ refusal is House's `{ error: { code, retryable } }`, `app_release_not_found` for an
55
59
  App with no release; a read House does not answer, or one that outlived
56
60
  the browser's signed-in User, is `null`.
57
61
  - **External calls** - `callExternal({ url, method, headers, body, slot })`
58
62
  has House's server send an HTTPS request to one of the release's declared
59
63
  external origins and resolves with the service's `status`, `headers` and its
60
64
  body as `body` text or `body_base64`, or a refusal as House's
61
- `{ error: { code } }`, or `null` when House does not answer. House fills in the
65
+ `{ error: { code, retryable } }`, or `null` when House does not answer. House fills in the
62
66
  App and its running release; naming an account slot places the key of the
63
67
  account the User connected to it, which the App never sees, and naming none
64
68
  sends no key. A Site's call names no slot, and the House panel names an
@@ -67,7 +71,7 @@
67
71
  another holder of this App sends to this User's running copies, as `from`,
68
72
  the sender's User id, and `body`, the JSON value they sent, until the
69
73
  function it returns is called. App code sends one through the Runtime's
70
- `call('apps/messages/send', { to, body })` with the recipient's User id and
74
+ `call('message.send', { to, body })` with the recipient's User id and
71
75
  a body of at most 32 KiB of JSON; House fills in the App and relays it only
72
76
  to copies running now, storing nothing. The panel sends none.
73
77
  - **App screenshot read** - given an App reference, `readScreenshot(app)`
@@ -82,23 +86,24 @@
82
86
 
83
87
  ## The test host
84
88
 
85
- The host plays the shell and the worker: it delivers the address the App starts
86
- at and each address `open` plays as Back or Forward through the shell's own
87
- slice below the App's path, admitting nothing, so it delivers `//notes` exactly
88
- as a browser at `/apps/<app>//notes` does. It records every push and replace
89
- and answers each with the address the shell would then show, records an
90
- admitted ceremony, refuses a push, replace or ceremony that is not a
91
- same-origin absolute path, publishes a status, its `service_mode` included,
92
- records in `consents` each
93
- `gaConsent` it takes while that status measures and publishes the status the
94
- choice leaves, records in `tabs` each `setTab` the shell would take and ignores
95
- any other, answers a release code read from the `sources` it was given, or
96
- `null`, answers each `callExternal` from the `external` function it was given, or
97
- `null`, and records the request in `externalCalls`, answers `readScreenshot`
98
- from the `screenshots` it was given by App
99
- reference and `readSiteScreenshot` from the `siteScreenshots` it was given by
100
- Site identifier, answers every `install()` `'manual'`, as a browser with no install
101
- prompt does, answers every `notifications()` `'unavailable'`, as a browser
102
- without push does, settles every `setBadge`, delivers each App message
103
- `message({ from, body })` plays, and hands `bridge.runtime` the MD Runtime
104
- client `attachRuntime(client)` gives it.
89
+ `createHouseBridgeTestHost(options)` plays House in one page: it installs a
90
+ bridge object that `houseBridge()` returns, until the next host replaces it.
91
+ It delivers the address the App starts at and each address `open` plays as
92
+ Back or Forward, admitting nothing, so it delivers `//notes` exactly as a
93
+ browser at `/apps/<app>//notes` does. It records every push and replace and
94
+ answers each with the address the shell would then show, records an admitted
95
+ ceremony, and refuses a push, replace or ceremony that is not a same-origin
96
+ absolute path of at most 1,000 characters with an error named
97
+ `HouseNavigationRefused` or `HouseCeremonyRefused`. It publishes a status,
98
+ its `serviceMode` included, records in `consents` each `analyticsConsent` it
99
+ takes while that status measures and publishes the status the choice leaves,
100
+ records in `tabs` each `setTab`, answers a release code read from the
101
+ `sources` it was given, or `null`, answers each `callExternal` from the
102
+ `external` function it was given, or `null`, and records the request in
103
+ `externalCalls`, answers `readScreenshot` from the `screenshots` it was given
104
+ by App reference and `readSiteScreenshot` from the `siteScreenshots` it was
105
+ given by Site identifier, answers every `install()` `'manual'`, as a browser
106
+ with no install prompt does, answers every `notifications()` `'unavailable'`,
107
+ as a browser without push does, settles every `setBadge`, delivers each App
108
+ message `message({ from, body })` plays, and hands `bridge.runtime` the MD
109
+ Runtime client `attachRuntime(client)` gives it.