@assembly-js/app-foundation 0.2.5 → 0.2.7
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/CHANGELOG.md +6 -0
- package/CLAUDE.md +6 -0
- package/bin/foundation-exports.mjs +1 -1
- package/dist/origin-bootstrap.d.ts +4 -6
- package/dist/origin-bootstrap.js +21 -30
- package/dist/paint-beacon.js +1 -9
- package/package.json +1 -1
- package/skills/assembly-app-clients/SKILL.md +80 -0
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.2.7
|
|
23
|
+
- [guidance] The build agent learns to call another app through its generated client and to show an empty state naming that app when the call is refused.
|
|
24
|
+
|
|
25
|
+
## 0.2.6
|
|
26
|
+
- [fix] The app keeps connecting to a portal on a custom domain, and keeps telling the editor when it has loaded, without relying on a platform setting that is being retired. Nothing visible changes.
|
|
27
|
+
|
|
22
28
|
## 0.2.5
|
|
23
29
|
- [new] The client-call scan no longer reports reading the session's token details, a client's allowed fetch routes, or a harmless local namespace import as breaking; nothing in the app changes.
|
|
24
30
|
|
package/CLAUDE.md
CHANGED
|
@@ -48,6 +48,9 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
48
48
|
<!-- feature:deep-link-bridge-nav -->
|
|
49
49
|
- `assembly-platform-links` — link platform records (client, company, invoice, contract, form, file) to their portal detail page via `linkTo()`; never hand-build a host URL. Also links between workspace apps by install id. Check the app's installed app-bridge first (see the skill) — `navigateTo` doesn't exist on older peers — and only link staff-only destinations (client, company, form, etc.) from the dashboard surface, never the client portal.
|
|
50
50
|
<!-- /feature:deep-link-bridge-nav -->
|
|
51
|
+
<!-- feature:cross-app-apis -->
|
|
52
|
+
- `assembly-app-clients` — calling another workspace app through its generated client in `generated/apps/<appId>/` (`installerClient`, by verb and path), and rendering an empty state that names the app when the call is refused instead of a 500.
|
|
53
|
+
<!-- /feature:cross-app-apis -->
|
|
51
54
|
- `assembly-ai` — AI features, including speech-to-text, via Assembly's OpenAI proxy (no API keys needed)
|
|
52
55
|
- `assembly-storage` — app-private file uploads/downloads (NOT the portal Files module). The storage helpers come from `@assembly-js/app-foundation/server` — import them, don't rewrite them. POST only `{ fileName }` (a bare name) and store the key the platform returns; never build the S3 key yourself.
|
|
53
56
|
- `assembly-integrations` (present only when third-party integrations are enabled for this workspace) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`. If this skill is present, read it and `.claude/skills/assembly-integrations/providers/<slug>.md` before declaring an integration or writing any provider call — the skill lists what's connectable, the provider doc has that provider's gotchas — and read them first when provider data displays wrongly (wrong format, raw IDs). If it is absent, managed OAuth integrations are not available here; use `requiredSecrets` for any external service
|
|
@@ -454,6 +457,9 @@ Relationships:
|
|
|
454
457
|
|
|
455
458
|
If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. While a person is using the app, those calls use the installer client, because the platform does not yet prove which person the call is for. Once it can, use the viewer client for that person's data and keep the installer client for staff-wide reads.
|
|
456
459
|
<!-- /feature:api-resources -->
|
|
460
|
+
<!-- feature:cross-app-apis -->
|
|
461
|
+
When `generated/apps/<appId>/` exists for that app, call it through the generated client instead (see the `assembly-app-clients` skill); the docs recipe is the fallback for an app with no folder there.
|
|
462
|
+
<!-- /feature:cross-app-apis -->
|
|
457
463
|
|
|
458
464
|
---
|
|
459
465
|
|
|
@@ -30,7 +30,7 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
30
30
|
'InternalUserAccess', 'LEGACY_APP_IDS', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
|
|
31
31
|
'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
|
|
32
32
|
'NetworkCaptureRecord', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
33
|
-
'PORTAL_ORIGIN_COOKIE_NAME', '
|
|
33
|
+
'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'PrincipalAllow', 'PrincipalKind',
|
|
34
34
|
'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
|
|
35
35
|
'RequestSource', 'ResolveResourceOpts', 'ResourceError', 'ResourceFieldMap',
|
|
36
36
|
'ResourceOption', 'ResourcePage', 'ScheduleCadence', 'SlackChannel',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
export declare const PORTAL_ORIGIN_COOKIE_NAME = "assembly-portal-origin";
|
|
2
2
|
export declare const CLIENT_FLAGS_COOKIE_NAME = "assembly-flags";
|
|
3
|
-
export declare const PREVIEW_V2_FLAG = "app-builder-preview-v2";
|
|
4
3
|
/**
|
|
5
4
|
* Reads a single cookie by name from a cookie string (defaults to
|
|
6
5
|
* `document.cookie`). Shared by every module that reads a proxy-mirrored
|
|
@@ -9,14 +8,13 @@ export declare const PREVIEW_V2_FLAG = "app-builder-preview-v2";
|
|
|
9
8
|
export declare function readCookie(name: string, cookieString?: string): string | null;
|
|
10
9
|
/**
|
|
11
10
|
* Whether the given flag key is present in the proxy-mirrored flags cookie.
|
|
12
|
-
* Exported
|
|
13
|
-
* cookie without re-parsing it themselves.
|
|
11
|
+
* Exported so client modules can gate on a flag without re-parsing the cookie.
|
|
14
12
|
*/
|
|
15
13
|
export declare function hasClientFlag(key: string, cookieString?: string): boolean;
|
|
16
14
|
/**
|
|
17
15
|
* Register the workspace's portal origin with the bridge allowlist — from the
|
|
18
|
-
* proxy-set cookie when
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* proxy-set cookie when it is readable, from the framing origin otherwise.
|
|
17
|
+
* Returns the origin it registered, or null when there was nothing to do.
|
|
18
|
+
* Exported for tests; runs once at module load in the browser.
|
|
21
19
|
*/
|
|
22
20
|
export declare function bootstrapPortalOrigin(cookieString?: string): string | null;
|
package/dist/origin-bootstrap.js
CHANGED
|
@@ -2,20 +2,18 @@
|
|
|
2
2
|
// evaluation precedes hydration — so the workspace's portal origin joins the
|
|
3
3
|
// bridge allowlist before any component's first header/session-token call.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// never selects whether anything registers at all (flag-off must keep custom
|
|
7
|
-
// portal domains working exactly as pre-vNext package versions did):
|
|
5
|
+
// Two trust sources, in order:
|
|
8
6
|
//
|
|
9
|
-
// -
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// -
|
|
14
|
-
// framing origin (ancestorOrigins, referrer
|
|
15
|
-
// sound because the proxy's CSP frame-ancestors
|
|
16
|
-
// frame the app, but it rides navigation state —
|
|
17
|
-
// modes and poisoned by in-iframe reloads — which is
|
|
18
|
-
//
|
|
7
|
+
// - the proxy-set cookie. The value is platform-resolved, never inferred
|
|
8
|
+
// client-side — the proxy already fetches workspace.portalUrl for its CSP
|
|
9
|
+
// frame-ancestors header and mirrors it into this client-readable cookie.
|
|
10
|
+
// Immune to the navigation-state poisoning below.
|
|
11
|
+
// - when that cookie is unreadable (a browser blocking the app's cookies):
|
|
12
|
+
// the browser-reported framing origin (ancestorOrigins, referrer
|
|
13
|
+
// fallback). Trusting it is sound because the proxy's CSP frame-ancestors
|
|
14
|
+
// already gates who may frame the app, but it rides navigation state —
|
|
15
|
+
// strip-able by privacy modes and poisoned by in-iframe reloads — which is
|
|
16
|
+
// why the cookie comes first.
|
|
19
17
|
//
|
|
20
18
|
// The bridge has no trust bypass, so one of these registrations is the only
|
|
21
19
|
// way a custom portal domain becomes a valid parent (dashboard/platform
|
|
@@ -29,7 +27,6 @@ export const PORTAL_ORIGIN_COOKIE_NAME = 'assembly-portal-origin';
|
|
|
29
27
|
// plain, comma-separated cookie on every request (set or cleared, never
|
|
30
28
|
// left stale).
|
|
31
29
|
export const CLIENT_FLAGS_COOKIE_NAME = 'assembly-flags';
|
|
32
|
-
export const PREVIEW_V2_FLAG = 'app-builder-preview-v2';
|
|
33
30
|
/**
|
|
34
31
|
* Reads a single cookie by name from a cookie string (defaults to
|
|
35
32
|
* `document.cookie`). Shared by every module that reads a proxy-mirrored
|
|
@@ -51,8 +48,7 @@ export function readCookie(name, cookieString) {
|
|
|
51
48
|
}
|
|
52
49
|
/**
|
|
53
50
|
* Whether the given flag key is present in the proxy-mirrored flags cookie.
|
|
54
|
-
* Exported
|
|
55
|
-
* cookie without re-parsing it themselves.
|
|
51
|
+
* Exported so client modules can gate on a flag without re-parsing the cookie.
|
|
56
52
|
*/
|
|
57
53
|
export function hasClientFlag(key, cookieString) {
|
|
58
54
|
const raw = readCookie(CLIENT_FLAGS_COOKIE_NAME, cookieString);
|
|
@@ -61,11 +57,11 @@ export function hasClientFlag(key, cookieString) {
|
|
|
61
57
|
return raw.split(',').includes(key);
|
|
62
58
|
}
|
|
63
59
|
/**
|
|
64
|
-
*
|
|
60
|
+
* Framing-origin resolution for when the portal-origin cookie is unreadable: `location.ancestorOrigins`
|
|
65
61
|
* (browser-attested; Chromium/WebKit), falling back to `document.referrer`
|
|
66
62
|
* (Firefox). Null outside an iframe or when neither source is available.
|
|
67
63
|
*/
|
|
68
|
-
function
|
|
64
|
+
function resolveFramingOrigin() {
|
|
69
65
|
if (typeof window === 'undefined')
|
|
70
66
|
return null;
|
|
71
67
|
if (window.parent === window)
|
|
@@ -85,25 +81,20 @@ function resolveLegacyParentOrigin() {
|
|
|
85
81
|
}
|
|
86
82
|
/**
|
|
87
83
|
* Register the workspace's portal origin with the bridge allowlist — from the
|
|
88
|
-
* proxy-set cookie when
|
|
89
|
-
*
|
|
90
|
-
*
|
|
84
|
+
* proxy-set cookie when it is readable, from the framing origin otherwise.
|
|
85
|
+
* Returns the origin it registered, or null when there was nothing to do.
|
|
86
|
+
* Exported for tests; runs once at module load in the browser.
|
|
91
87
|
*/
|
|
92
88
|
export function bootstrapPortalOrigin(cookieString) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
// already on the default allowlist.
|
|
98
|
-
const origin = resolveLegacyParentOrigin();
|
|
89
|
+
const record = readCookie(PORTAL_ORIGIN_COOKIE_NAME, cookieString);
|
|
90
|
+
if (!record) {
|
|
91
|
+
// Https-only: local dev origins are already on the default allowlist.
|
|
92
|
+
const origin = resolveFramingOrigin();
|
|
99
93
|
if (!origin || !origin.startsWith('https://'))
|
|
100
94
|
return null;
|
|
101
95
|
AssemblyBridge.configure({ additionalOrigins: [origin] });
|
|
102
96
|
return origin;
|
|
103
97
|
}
|
|
104
|
-
const record = readCookie(PORTAL_ORIGIN_COOKIE_NAME, cookieString);
|
|
105
|
-
if (!record)
|
|
106
|
-
return null;
|
|
107
98
|
// Accept only a bare https origin — anything else (paths, http, garbage)
|
|
108
99
|
// is not a value the proxy writes and must not reach the allowlist.
|
|
109
100
|
try {
|
package/dist/paint-beacon.js
CHANGED
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
// The HTTP route is handled in proxy.ts via the helpers below (same
|
|
7
7
|
// package-delegation pattern as session-exchange). The bridge post rides
|
|
8
8
|
// buffer-until-learned, so a paint that beats the parent's hello is not lost.
|
|
9
|
-
//
|
|
10
|
-
// Gated on the preview-v2 flag (see origin-bootstrap.ts's hasClientFlag):
|
|
11
|
-
// installPaintBeacon() no-ops for a flag-off workspace, so the client never
|
|
12
|
-
// calls sendBeacon and the proxy's route never sees a request — no need to
|
|
13
|
-
// separately gate handlePaintedBeacon() below.
|
|
14
9
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
15
|
-
import { hasClientFlag, PREVIEW_V2_FLAG } from './origin-bootstrap.js';
|
|
16
10
|
export const PAINTED_BEACON_PATH = '/__assembly/painted';
|
|
17
11
|
export const PAINTED_BEACON_EVENT = 'assembly.painted';
|
|
18
12
|
let scheduled = false;
|
|
@@ -46,9 +40,7 @@ function firePaintBeacon() {
|
|
|
46
40
|
* hydration + rAF wins. Does nothing on the server.
|
|
47
41
|
*/
|
|
48
42
|
export function installPaintBeacon() {
|
|
49
|
-
if (typeof window === 'undefined' || scheduled)
|
|
50
|
-
return;
|
|
51
|
-
if (!hasClientFlag(PREVIEW_V2_FLAG))
|
|
43
|
+
if (typeof window === 'undefined' || typeof document === 'undefined' || scheduled)
|
|
52
44
|
return;
|
|
53
45
|
scheduled = true;
|
|
54
46
|
const afterPaint = () => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!-- feature:cross-app-apis -->
|
|
2
|
+
---
|
|
3
|
+
name: assembly-app-clients
|
|
4
|
+
description: Use when the app calls another workspace app and `generated/apps/<appId>/` exists for it. Covers reading the generated README and types, calling `installerClient` by verb and path, and rendering an empty state that names the app when the call is refused (app_not_found, operation_not_found, viewer_not_allowed, callee_error) instead of throwing a 500.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Where the client lives
|
|
8
|
+
|
|
9
|
+
Each app this one may call has a folder at `generated/apps/<appId>/`, refreshed by the platform at the start of every turn:
|
|
10
|
+
|
|
11
|
+
- `README.md` — read it first. It lists every operation as a verb and a path, with a summary.
|
|
12
|
+
- `types.d.ts` — the request and response shapes of each operation. Read the one you need before writing the call.
|
|
13
|
+
- `client.ts` — exports `installerClient` and `viewerClient`.
|
|
14
|
+
- `openapi.json` — the contract the other files were generated from.
|
|
15
|
+
|
|
16
|
+
Never edit these files; the next refresh overwrites them. If an app has no folder here, it has published no contract: follow the call recipe in its `.assembly/docs/apps/` file instead.
|
|
17
|
+
|
|
18
|
+
## Calling another app
|
|
19
|
+
|
|
20
|
+
Call an operation by its verb and path, exactly as the README lists it. Paths, parameters and bodies are type-checked against the contract, so a wrong path fails the type check instead of production.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { installerClient } from '@/generated/apps/<appId>/client';
|
|
24
|
+
|
|
25
|
+
const { data, error } = await installerClient.GET('/projects/{id}', {
|
|
26
|
+
params: { path: { id } },
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Call it from server code only: pages, server actions, route handlers, cron and webhooks. Never import it into a `'use client'` component.
|
|
31
|
+
|
|
32
|
+
## Installer or viewer
|
|
33
|
+
|
|
34
|
+
Use `installerClient`. It calls as this app, so filter anything it returns to what the viewing client may see before you show it to a client.
|
|
35
|
+
|
|
36
|
+
`viewerClient` calls as the signed-in person. The platform refuses it with `viewer_not_allowed` until it can prove which person the call is for, so use `installerClient` for now.
|
|
37
|
+
|
|
38
|
+
## When the other app cannot answer
|
|
39
|
+
|
|
40
|
+
The other app can be uninstalled, can drop an operation, or can fail on its own. None of these may crash the page. A refusal does not throw: the call returns no `data` and an `error` whose `code` says why.
|
|
41
|
+
|
|
42
|
+
| `code` | Meaning | What to render |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `app_not_found` | The app is not installed in this workspace, or this app may not call it. | Empty state: "<App name> is not available in this workspace." |
|
|
45
|
+
| `operation_not_found` | The app no longer offers this operation. | Empty state: "<App name> no longer provides this." |
|
|
46
|
+
| `viewer_not_allowed` | The call used `viewerClient`. | Nothing at runtime: switch the call to `installerClient`. |
|
|
47
|
+
| `callee_error` | The other app answered with its own failure. The most common case at runtime. | Error state: "<App name> could not load this right now." |
|
|
48
|
+
|
|
49
|
+
Branch on the result and render a state that names the app:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { installerClient } from '@/generated/apps/<appId>/client';
|
|
53
|
+
|
|
54
|
+
const PROVIDER_NAME = 'Projects';
|
|
55
|
+
const MISSING_CODES = ['app_not_found', 'operation_not_found'];
|
|
56
|
+
|
|
57
|
+
function errorCode(error: unknown): string | undefined {
|
|
58
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
|
59
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default async function ProjectsPanel() {
|
|
63
|
+
const { data, error } = await installerClient.GET('/projects');
|
|
64
|
+
if (!data) {
|
|
65
|
+
const code = errorCode(error);
|
|
66
|
+
if (code && MISSING_CODES.includes(code)) {
|
|
67
|
+
return <EmptyState title={`${PROVIDER_NAME} is not available in this workspace.`} />;
|
|
68
|
+
}
|
|
69
|
+
console.error('projects call failed', code ?? error);
|
|
70
|
+
return <EmptyState title={`${PROVIDER_NAME} could not load this right now.`} />;
|
|
71
|
+
}
|
|
72
|
+
return <ProjectList projects={data} />;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Only the part of the page that depends on the other app shows the empty state; render the rest as usual.
|
|
77
|
+
- A network failure throws instead of returning `error`. Wrap the call in `try`/`catch` where the page must still render, and show the same error state.
|
|
78
|
+
- In a server action, return `{ ok: false, message }` naming the app (see `assembly-action-results`) instead of throwing.
|
|
79
|
+
- In a route handler, answer with a non-500 status and a static message naming the app (see `assembly-route-errors`). Never forward the other app's message to a client.
|
|
80
|
+
<!-- /feature:cross-app-apis -->
|