@assembly-js/app-foundation 0.0.11 → 0.0.12

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.
@@ -310,7 +310,8 @@ const MAX_ACTION_DESC_LENGTH = 40;
310
310
  const MAX_PATH_LENGTH = 512;
311
311
 
312
312
  const RESOURCE_NAME_RE = /^[a-z][a-zA-Z0-9]{1,63}$/;
313
- const EVENT_NAME_RE = /^[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/;
313
+ const EVENT_NAME_RE = /^(~app-[a-z0-9]+\.)?[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/;
314
+ const APP_EVENT_MARKER_RE = /^~app-[a-z0-9]+\./;
314
315
  const API_PATH_RE = /^(\/[a-z0-9\-_]+)+(\/:id)?$/;
315
316
 
316
317
  const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
@@ -530,22 +531,33 @@ function camelCaseAlnum(s) {
530
531
  return out;
531
532
  }
532
533
 
534
+ // Mirrors marketplace_application.PeelAppEventMarker: splits a possibly-namespaced
535
+ // event name into its "~<appId>." marker (empty when absent) and the bare
536
+ // {resourceName}.{verb} remainder.
537
+ function peelAppEventMarker(eventName) {
538
+ const m = APP_EVENT_MARKER_RE.exec(eventName);
539
+ const marker = m ? m[0] : '';
540
+ return { marker, bare: eventName.slice(marker.length) };
541
+ }
542
+
533
543
  // Mirrors normalizeAppApiEventName in app_api_resources.go: the publish gate
534
544
  // coerces an authored eventName toward the canonical {resourceName}.{verb}
535
545
  // form instead of rejecting it ("intake.review_status_changed" →
536
546
  // "intake.reviewStatusChanged"; a bare verb gains the record's resourceName;
537
- // an empty verb becomes "triggered"). Returns the input unchanged when
538
- // nothing salvageable remains, so the format error still fires.
547
+ // an empty verb becomes "triggered"). Any leading "~<appId>." marker is peeled
548
+ // off, normalized around, and re-attached verbatim. Returns the input unchanged
549
+ // when nothing salvageable remains, so the format error still fires.
539
550
  function normalizeEventName(resourceName, eventName) {
551
+ const { marker, bare } = peelAppEventMarker(eventName);
540
552
  let prefix = resourceName;
541
- let verb = eventName;
542
- const dot = eventName.indexOf('.');
553
+ let verb = bare;
554
+ const dot = bare.indexOf('.');
543
555
  if (dot >= 0) {
544
- prefix = camelCaseAlnum(eventName.slice(0, dot)) || resourceName;
545
- verb = eventName.slice(dot + 1);
556
+ prefix = camelCaseAlnum(bare.slice(0, dot)) || resourceName;
557
+ verb = bare.slice(dot + 1);
546
558
  }
547
559
  verb = camelCaseAlnum(verb) || 'triggered';
548
- const normalized = prefix + '.' + verb;
560
+ const normalized = marker + prefix + '.' + verb;
549
561
  return EVENT_NAME_RE.test(normalized) ? normalized : eventName;
550
562
  }
551
563
 
@@ -1025,16 +1037,21 @@ if (existsSync(API_RESOURCES)) {
1025
1037
  if (!EVENT_NAME_RE.test(eventName)) {
1026
1038
  apiErrors.push(webhookWhere + ': eventName "' + eventName + '" must be {resourceName}.{verb}.');
1027
1039
  } else {
1028
- const prefix = eventName.slice(0, eventName.indexOf('.'));
1040
+ // A namespaced app event carries a "~<appId>." marker that is opaque
1041
+ // to the resourceName and native-collision checks; validate the bare
1042
+ // {resourceName}.{verb} remainder.
1043
+ const bare = peelAppEventMarker(eventName).bare;
1044
+ const prefix = bare.slice(0, bare.indexOf('.'));
1029
1045
  if (!resourceNames.has(prefix)) {
1030
1046
  apiErrors.push(webhookWhere + ': eventName "' + eventName + '" must be prefixed by one of the manifest\'s resourceNames.');
1031
1047
  }
1032
- const lowerEventName = eventName.toLowerCase();
1033
- if (NATIVE_EVENTS_LOWER.has(lowerEventName)) {
1048
+ const lowerBare = bare.toLowerCase();
1049
+ if (NATIVE_EVENTS_LOWER.has(lowerBare)) {
1034
1050
  apiErrors.push(webhookWhere + ': eventName "' + eventName + '" is a native platform event name.');
1035
1051
  } else if (NATIVE_NAMESPACES_LOWER.has(prefix.toLowerCase())) {
1036
1052
  apiErrors.push(webhookWhere + ': eventName "' + eventName + '" uses the native "' + prefix + '" event namespace; pick a distinct resourceName.');
1037
1053
  }
1054
+ const lowerEventName = eventName.toLowerCase();
1038
1055
  if (seenEventNames.has(lowerEventName)) {
1039
1056
  apiErrors.push(webhookWhere + ': eventName "' + eventName + '" is already declared by ' + seenEventNames.get(lowerEventName) + ' (event names are matched case-insensitively).');
1040
1057
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -63,7 +63,7 @@ The entire file is one top-level key. Unknown keys anywhere are publish errors,
63
63
  ],
64
64
  "apis": [
65
65
  {
66
- "name": "Create time entry",
66
+ "name": "~app-43574726.Create time entry",
67
67
  "description": "Log a time entry",
68
68
  "method": "POST",
69
69
  "path": "/time-entries",
@@ -84,7 +84,7 @@ The entire file is one top-level key. Unknown keys anywhere are publish errors,
84
84
  ],
85
85
  "webhooks": [
86
86
  {
87
- "eventName": "timeEntry.created",
87
+ "eventName": "~app-43574726.timeEntry.created",
88
88
  "eventDescription": "Fires when a time entry is logged",
89
89
  "trigger": {
90
90
  "name": "Time entry logged",
@@ -94,7 +94,7 @@ The entire file is one top-level key. Unknown keys anywhere are publish errors,
94
94
  }
95
95
  },
96
96
  {
97
- "eventName": "timeEntry.approved",
97
+ "eventName": "~app-43574726.timeEntry.approved",
98
98
  "eventDescription": "Fires when a time entry is approved",
99
99
  "trigger": {
100
100
  "name": "Time entry approved",
@@ -223,7 +223,7 @@ const userAgent = req.headers.get('user-agent') ?? '';
223
223
 
224
224
  after(async () => {
225
225
  try {
226
- const res = await assembly.fetch('/v1/webhooks/timeEntry.created', {
226
+ const res = await assembly.fetch('/v1/webhooks/~app-43574726.timeEntry.created', {
227
227
  method: 'POST',
228
228
  headers: {
229
229
  'X-Assembly-Actor-Id': actorId,
@@ -242,7 +242,7 @@ after(async () => {
242
242
  });
243
243
  ```
244
244
 
245
- - **`assembly.fetch` is how an event is emitted** — it adds the app's auth and the JSON content type for you. The path is `/v1/webhooks/<eventName>` using the exact `eventName` from this file; the platform validates the name and payload against this manifest's registration, then runs matching automations.
245
+ - **`assembly.fetch` is how an event is emitted** — it adds the app's auth and the JSON content type for you. The path is `/v1/webhooks/<eventName>` using the **exact** `eventName` from this file, `~<appId>.` prefix and all (the `~` is URL-safe, so the path needs no encoding); the platform validates the name and payload against this manifest's registration, then runs matching automations.
246
246
  - **Check `res.ok`.** `assembly.fetch` resolves on a 4xx instead of throwing, so an unregistered event name or an invalid payload is a silent no-op unless you read the status — the easiest way there is to ship a trigger that never fires.
247
247
  - **Emit inside `after()`, and never leave the call un-awaited.** `after()` runs the emit once the response is sent, so it never delays or breaks the user's action — log the failure there and continue. Dropping the `await` to keep the handler fast looks equivalent and is not: a floating promise is frozen when the handler returns, so the event arrives minutes late or never, and which one you get is luck.
248
248
  - **Capture the request values in the handler, not inside the callback.** `after()` runs past the response, so read `actorId` / IP / user-agent before it and close over them.
@@ -471,14 +471,15 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
471
471
 
472
472
  ## Rules the publish gate enforces
473
473
 
474
- - **Event names are `{resourceName}.{verb}`** — `timeEntry.created`, never a bare `created` and never a native platform event name (`task.completed`, `client.created`, …). Unique within the app, case-insensitively. Both segments are single alphanumeric camelCase tokens — no underscores, hyphens, or extra dots: `intake.reviewStatusChanged`, never `intake.review_status_changed`. The publish gate auto-normalizes non-conforming names to camelCase, so the event name your runtime code emits must be the canonical form — write camelCase everywhere.
474
+ - **Namespace every event name and exposed action with your app id.** Prefix each `eventName`, and the `name` of every api you set `exposeToAutomations: true` on, with `~$ASSEMBLY_APP_ID.` — read the id from the `ASSEMBLY_APP_ID` environment variable (`echo $ASSEMBLY_APP_ID`, it looks like `app-43574726`) and write the literal value in. This is what keeps your triggers and actions from colliding with another app installed in the same workspace, and the prefix is stripped before the user ever sees it. Prefix only **new** events/actions — never re-prefix or rename one you already shipped (see the renaming rule below).
475
+ - **Event names are `~<appId>.{resourceName}.{verb}`** — e.g. `~app-43574726.timeEntry.created`. The `{resourceName}.{verb}` remainder is still exactly two single alphanumeric camelCase tokens: no bare `created`, no native platform event name (`task.completed`, `client.created`, …), and no underscores, hyphens, or extra dots inside the remainder — `~app-43574726.intake.reviewStatusChanged`, never `intake.review_status_changed`. The publish gate normalizes only the remainder to camelCase and carries the `~<appId>.` prefix through untouched, so the event name your runtime code emits must be byte-identical to what you declare — write it the same in both places.
475
476
  - **One action = one record + method.** `exposeToAutomations: true` requires an `input` with at least one field where `automationSettings.isEnabled` is true. `(method, path)` pairs must be unique across the whole file.
476
477
  - **The zero-value rule**: the platform treats a zero value (`0`, `""`, `false`) on a required field as missing. Declare booleans and legitimately-zeroable numbers `optional: true`; a required `bool` field is rejected outright.
477
478
  - **`dynamicOptions`** on action fields (`{ "requestUrl": "/projects", "nameField": "name" }`) points a dropdown at a **GET api registered by any record in this manifest** — cross-resource references are normal (a `feedbackItem` input listing categories points at the `category` record's GET api). The `requestUrl` must equal that api's `path`; `/:id` forms don't qualify. The endpoint must return `{ "data": [ { "id": "...", "name": "..." } ] }` — `nameField` may be nested/composite (`name.first|name.last`). The referenced api must live in a record whose `baseUrl` actually serves it and whose `schema[]` the response satisfies — see "Serving dynamic options". Use `dropdownSource` instead when the choice is an Assembly client/company/internal user.
478
479
  - Paths are lowercase `[a-z0-9-_]` segments with an optional trailing `/:id`, and must not contain `tasks`, `task-templates`, or `comments` — those route to the platform's Tasks app.
479
480
  - **`apis[].description` max 40 characters** (all apis). It becomes the action's label — see above.
480
481
  - **`trigger.icon`, when set, must be one of the names in the icon list above.** Unknown names fail the publish; an omitted icon renders a generic default.
481
- - **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName`; renaming either orphans every automation a workspace built on it. Pick names as if they're permanent.
482
+ - **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName` — including the `~<appId>.` prefix, which is part of that identity. Renaming either, or adding/removing the prefix on one you already shipped, orphans every automation a workspace built on it. Pick names as if they're permanent.
482
483
 
483
484
  ## Telling the user
484
485