@assembly-js/app-foundation 0.0.1-rc.2 → 0.0.2-rc.1
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/CLAUDE.md +16 -0
- package/package.json +5 -3
- package/render-context.mjs +96 -0
- package/skills/assembly-app-surfaces/SKILL.md +104 -0
- package/skills/assembly-automations/SKILL.md +456 -0
- package/skills/assembly-integrations/SKILL.md +205 -0
- package/skills/assembly-notifications/SKILL.md +503 -0
package/CLAUDE.md
CHANGED
|
@@ -23,11 +23,22 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
23
23
|
- `assembly-custom-fields` — workspace-defined fields, multiSelect rules
|
|
24
24
|
- `assembly-native-resources` — native message channels, forms, portal files/file channels: never hardcode their IDs, discover them at runtime
|
|
25
25
|
- `assembly-mutations` — minimal `requestBody` rule for `update*` calls
|
|
26
|
+
<!-- !feature:notification-settings -->
|
|
27
|
+
- `assembly-notifications` — in-product + email notifications via createNotification; `ctaParams` for notification CTA URLs (the `/n` surface renders from those, not the best-effort `notificationId` claim)
|
|
28
|
+
<!-- /feature:notification-settings -->
|
|
29
|
+
<!-- feature:notification-settings -->
|
|
30
|
+
- `assembly-notifications` — in-product + email notifications via createNotification; `ctaParams` for notification CTA URLs (the `/n` surface renders from those, not the best-effort `notificationId` claim); per-user toggleable categories via manifest `notificationSettings` + `notificationSettingId`
|
|
31
|
+
<!-- /feature:notification-settings -->
|
|
26
32
|
- `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
|
|
33
|
+
<!-- feature:api-resources -->
|
|
34
|
+
- `assembly-automations` — declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`): domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
|
|
35
|
+
<!-- /feature:api-resources -->
|
|
36
|
+
- `assembly-app-surfaces` — REQUIRED before writing `.assembly/app.json`: declare which surfaces (dashboard, client portal, notification) the app exposes; omitting the client surface keeps the app off client portal nav entirely
|
|
27
37
|
- `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
|
|
28
38
|
- `assembly-app-bridge-toast` — show toast notifications in the parent dashboard
|
|
29
39
|
- `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
|
|
30
40
|
- `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.
|
|
41
|
+
- `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 before declaring an integration or writing any provider call — it lists what's connectable and each provider's gotchas. If it is absent, managed OAuth integrations are not available here; use `requiredSecrets` for any external service
|
|
31
42
|
- `assembly-avatar-sub` — row-duplication + AvatarSub pattern for showing which company a client row represents (multi-company clients)
|
|
32
43
|
- `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
|
|
33
44
|
- `assembly-forms` — react-hook-form + zod + Field component validation pattern
|
|
@@ -226,7 +237,12 @@ Any external service (SendGrid, Twilio, Resend, a niche SaaS, an API-key-only se
|
|
|
226
237
|
- `type` — `url | api_key | secret | text`. The platform picks input style from this.
|
|
227
238
|
- `docsUrl` — optional link the platform renders as "Where do I find this?".
|
|
228
239
|
|
|
240
|
+
<!-- !feature:notification-settings -->
|
|
229
241
|
Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave it in place if present — it is platform-managed).
|
|
242
|
+
<!-- /feature:notification-settings -->
|
|
243
|
+
<!-- feature:notification-settings -->
|
|
244
|
+
Valid top-level manifest keys: `surfaces`, `requiredSecrets`, `notificationSettings` (per-user toggleable internal-user notification categories, plus an optional app-level `actionLabel` that gives the app a row in the *client's* Your Actions card — see the `assembly-notifications` skill), and `requiredIntegrations` (leave it in place if present — it is platform-managed).
|
|
245
|
+
<!-- /feature:notification-settings -->
|
|
230
246
|
|
|
231
247
|
### Example: post to a Slack webhook from a route handler
|
|
232
248
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2-rc.1",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
8
|
"skills",
|
|
9
|
-
"CLAUDE.md"
|
|
9
|
+
"CLAUDE.md",
|
|
10
|
+
"render-context.mjs"
|
|
10
11
|
],
|
|
11
12
|
"exports": {
|
|
12
13
|
"./server": {
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
}
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
|
22
|
-
"build": "tsc",
|
|
23
|
+
"build": "tsc && tsc-alias",
|
|
23
24
|
"test": "bun test",
|
|
24
25
|
"prepublishOnly": "npm run build && npm run verify:dist",
|
|
25
26
|
"verify:dist": "node scripts/verify-dist-resolves.mjs"
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"@types/node": "^22",
|
|
36
37
|
"next": "^15.5.0",
|
|
37
38
|
"server-only": "^0.0.1",
|
|
39
|
+
"tsc-alias": "^1.9.1",
|
|
38
40
|
"typescript": "^5.3.3"
|
|
39
41
|
},
|
|
40
42
|
"publishConfig": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Renders this package's prose for one app: strips the feature-gate blocks the
|
|
2
|
+
// app's flags don't enable, and substitutes the catalog tokens.
|
|
3
|
+
//
|
|
4
|
+
// A port of prompts.RenderFeatureGates / parseFeatureMarker in the Go tree. The
|
|
5
|
+
// duplication is deliberate — the gates interleave with prose line by line, so the
|
|
6
|
+
// side that writes the files has to do the stripping, and that side is now the
|
|
7
|
+
// sandbox. render-context.test.mjs pins the two implementations to a shared fixture
|
|
8
|
+
// so a divergence fails a build rather than silently shipping wrong prose.
|
|
9
|
+
//
|
|
10
|
+
// Catalogs arrive pre-rendered from Go instead of as data: the action catalog
|
|
11
|
+
// branches on advertised resolvers, cross-provider resolvers, and a no-resolver
|
|
12
|
+
// fallback, and re-deriving that here would be a second implementation of logic
|
|
13
|
+
// that exists in one place today.
|
|
14
|
+
|
|
15
|
+
// A marker is a full line, optionally prefixed with "# " so it can sit inside YAML
|
|
16
|
+
// frontmatter without breaking the parse.
|
|
17
|
+
function parseFeatureMarker(line) {
|
|
18
|
+
let trimmed = line.trim();
|
|
19
|
+
if (trimmed.startsWith('# ')) trimmed = trimmed.slice(2);
|
|
20
|
+
if (!trimmed.startsWith('<!-- ') || !trimmed.endsWith(' -->')) return null;
|
|
21
|
+
|
|
22
|
+
let inner = trimmed.slice('<!-- '.length, -' -->'.length);
|
|
23
|
+
const marker = { name: '', close: false, negated: false };
|
|
24
|
+
|
|
25
|
+
if (inner.startsWith('/')) {
|
|
26
|
+
marker.close = true;
|
|
27
|
+
// Tolerate "/!feature:…" so a negated block can be closed symmetrically;
|
|
28
|
+
// polarity comes from the open marker either way.
|
|
29
|
+
inner = inner.slice(1);
|
|
30
|
+
if (inner.startsWith('!')) inner = inner.slice(1);
|
|
31
|
+
} else if (inner.startsWith('!')) {
|
|
32
|
+
marker.negated = true;
|
|
33
|
+
inner = inner.slice(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!inner.startsWith('feature:')) return null;
|
|
37
|
+
marker.name = inner.slice('feature:'.length);
|
|
38
|
+
return marker;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Keeps the lines this app's features select. Throws on a malformed gate rather than
|
|
42
|
+
// guessing: a mismatched marker means the prose is not what its author intended, and
|
|
43
|
+
// shipping half a gated block is worse than failing the turn.
|
|
44
|
+
export function renderFeatureGates(content, features) {
|
|
45
|
+
if (!content.includes('feature:')) return content;
|
|
46
|
+
|
|
47
|
+
const enabled = new Set(features ?? []);
|
|
48
|
+
const out = [];
|
|
49
|
+
let open = null;
|
|
50
|
+
let include = true;
|
|
51
|
+
|
|
52
|
+
// Split keeping line terminators so the output is byte-identical to the input
|
|
53
|
+
// outside the stripped blocks.
|
|
54
|
+
const lines = content.split(/(?<=\n)/);
|
|
55
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
const marker = parseFeatureMarker(line);
|
|
58
|
+
if (!marker) {
|
|
59
|
+
if (include) out.push(line);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (marker.close) {
|
|
63
|
+
if (!open || open.name !== marker.name) {
|
|
64
|
+
throw new Error(`line ${i + 1}: unmatched close marker for feature "${marker.name}"`);
|
|
65
|
+
}
|
|
66
|
+
open = null;
|
|
67
|
+
include = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (open) {
|
|
71
|
+
throw new Error(`line ${i + 1}: nested open marker for feature "${marker.name}" (blocks cannot nest)`);
|
|
72
|
+
}
|
|
73
|
+
open = marker;
|
|
74
|
+
include = enabled.has(marker.name) !== marker.negated;
|
|
75
|
+
}
|
|
76
|
+
if (open) {
|
|
77
|
+
throw new Error(`unclosed gate marker for feature "${open.name}"`);
|
|
78
|
+
}
|
|
79
|
+
return out.join('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Replaces each catalog token with the markdown Go rendered for it. A token with no
|
|
83
|
+
// entry is left alone: the config omits catalogs when the workspace cannot reach a
|
|
84
|
+
// provider, and the surrounding prose is written to read correctly either way.
|
|
85
|
+
export function substituteCatalogs(content, catalogs) {
|
|
86
|
+
let out = content;
|
|
87
|
+
for (const [token, markdown] of Object.entries(catalogs ?? {})) {
|
|
88
|
+
out = out.split(token).join(markdown);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The whole render for one file.
|
|
94
|
+
export function renderContextFile(content, config) {
|
|
95
|
+
return substituteCatalogs(renderFeatureGates(content, config?.features), config?.catalogs);
|
|
96
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-app-surfaces
|
|
3
|
+
description: Use whenever generating a Vibe app to declare which surfaces (dashboard-internal, client portal, or both) the app exposes. Omitting the client surface keeps the app off client portal nav entirely. The choice is a product decision, not a default.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every generated app must declare its **surfaces** in a `.assembly/app.json` file at the repo root. Note: `surfaces` declares surface *types* (dashboard, client portal, notification) — per-client or per-company visibility is a platform setting configured in the app's settings, not in this file. The build pipeline reads this file after deploy and uses it to populate the marketplace app's `InternalUrl` (dashboard preview) and `ClientUrl` (client portal). If you omit a surface, that URL stays empty — and an empty `ClientUrl` means the app does not appear in client portal nav at all.
|
|
7
|
+
|
|
8
|
+
## First decide: does this app have a client view?
|
|
9
|
+
|
|
10
|
+
A Vibe app has a **client view** only if end-clients (the customers of the workspace, not the workspace's internal users) are meant to interact with it directly inside the client portal. Examples:
|
|
11
|
+
|
|
12
|
+
- **Internal-only** (omit `client`): admin dashboards, internal reporting, time trackers used only by employees, internal CRM views.
|
|
13
|
+
- **Client-facing** (include `client`): self-serve client request forms, client-visible status pages, client-shared file browsers.
|
|
14
|
+
|
|
15
|
+
If you are unsure, default to **internal-only**. Do not add a client surface unless the prompt clearly describes a client-facing experience.
|
|
16
|
+
|
|
17
|
+
## File format
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"surfaces": {
|
|
22
|
+
"internal": { "path": "/" },
|
|
23
|
+
"client": { "path": "/c" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `internal` is **required** — every Vibe app must have a dashboard surface. `client` is optional; omit it for internal-only apps. `notification` is optional; declare it only when the app uses `ctaParams` in notifications (see below).
|
|
29
|
+
- `path` is the route within your app for that surface. Must start with `/`. Use `/` for the surface's root; use distinct sub-paths if both surfaces share one Next.js app.
|
|
30
|
+
- Generate the corresponding routes in your app code. E.g. if you declare `client.path = "/c"`, you must have a `/c` page in the app.
|
|
31
|
+
|
|
32
|
+
<!-- !feature:notification-settings -->
|
|
33
|
+
> **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs `node .assembly/validate-manifest.mjs` first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
|
|
34
|
+
|
|
35
|
+
`requiredSecrets` and `requiredIntegrations` are **top-level siblings of `surfaces`**, not surfaces — see the secrets guidance and the `assembly-integrations` skill. The full manifest shape is:
|
|
36
|
+
<!-- /feature:notification-settings -->
|
|
37
|
+
<!-- feature:notification-settings -->
|
|
38
|
+
> **Every surface MUST be nested under `surfaces`.** A surface placed at the top level — e.g. `{ "surfaces": {...}, "notification": { "path": "/n" } }` — is silently dropped by the deploy parser, leaving that surface's URL empty and its notification links broken. The only valid top-level keys are `surfaces`, `requiredSecrets`, `notificationSettings`, and `requiredIntegrations` (see below). The build validates this: `bun run build` runs `node .assembly/validate-manifest.mjs` first and **fails with a specific error** if the manifest is malformed or a key is misplaced. Run `bun run validate:manifest` to check it directly.
|
|
39
|
+
|
|
40
|
+
`requiredSecrets`, `notificationSettings`, and `requiredIntegrations` are **top-level siblings of `surfaces`**, not surfaces — see the `assembly-notifications` skill, the secrets guidance, and the `assembly-integrations` skill. The full manifest shape is:
|
|
41
|
+
<!-- /feature:notification-settings -->
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"surfaces": { "internal": { "path": "/" }, "client": { "path": "/c" }, "notification": { "path": "/n" } },
|
|
46
|
+
"requiredSecrets": [{ "key": "SENDGRID_API_KEY", "label": "SendGrid API key" }],
|
|
47
|
+
"requiredIntegrations": [{ "provider": "slack", "scopes": ["chat:write", "channels:read"] }]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
<!-- feature:notification-settings -->
|
|
51
|
+
|
|
52
|
+
`notificationSettings` declares per-user toggleable internal-user notification categories — schema and rules in the `assembly-notifications` skill.
|
|
53
|
+
<!-- /feature:notification-settings -->
|
|
54
|
+
<!-- feature:api-resources -->
|
|
55
|
+
|
|
56
|
+
The app's API contract (automation triggers/actions, webhook events) is NOT an `app.json` key: it lives in a separate sibling file, `.assembly/api-resources.json` — see the `assembly-automations` skill.
|
|
57
|
+
<!-- /feature:api-resources -->
|
|
58
|
+
|
|
59
|
+
## The `notification` surface
|
|
60
|
+
|
|
61
|
+
When an app sends notifications with `ctaParams`, the Notification Center needs to know where to send the user when they click the notification's CTA. Declare a `notification` surface to set that destination URL.
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"surfaces": {
|
|
66
|
+
"internal": { "path": "/" },
|
|
67
|
+
"notification": { "path": "/n" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
When `notification` is declared:
|
|
73
|
+
- The build pipeline sets `notificationUrl` on the marketplace app to `<deployURL>/n`.
|
|
74
|
+
- When a user clicks a notification CTA, the portal opens the app at `notificationUrl?token=<jwt>&<ctaParams>`.
|
|
75
|
+
- Your `/n` page shows its context-specific content from the `ctaParams` in the URL. Those always arrive; `notificationId` from the token payload is best-effort under the session-exchange flow, so never gate the record render on it.
|
|
76
|
+
|
|
77
|
+
**Declare the `notification` surface whenever the app sends notifications with `ctaParams`.** If notifications have no `ctaParams`, the surface is optional. See the `assembly-notifications` skill for the full CTA params pattern.
|
|
78
|
+
|
|
79
|
+
## Examples
|
|
80
|
+
|
|
81
|
+
Dashboard-only time tracker:
|
|
82
|
+
```json
|
|
83
|
+
{ "surfaces": { "internal": { "path": "/" } } }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
App with both surfaces in one Next.js project:
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"surfaces": {
|
|
90
|
+
"internal": { "path": "/" },
|
|
91
|
+
"client": { "path": "/c" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
App with notifications that have CTA params:
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"surfaces": {
|
|
100
|
+
"internal": { "path": "/" },
|
|
101
|
+
"notification": { "path": "/n" }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|