@assembly-js/app-foundation 0.4.2 → 0.5.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.
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.5.0
23
+ - [new] `detectPdfFields` from `@assembly-js/app-foundation/server` finds the fields on a PDF form: AcroForm fields with their page, kind and options; an XFA form reported as such; or, for a PDF with only text, fields suggested by Assembly AI when an Assembly client is passed, with the page text returned either way. A file that is not a PDF throws `PdfUnreadableError`.
24
+
25
+ ## 0.4.3
26
+ - [guidance] The build agent now knows `viewerClient` works for cross-app calls made by a client contact, and keeps `installerClient` for team members, cron, webhooks and background work.
27
+
22
28
  ## 0.4.2
23
29
  - [guidance] The build agent's main guide now says a client contact's cross-app calls use `viewerClient`, while team members and background work keep `installerClient`.
24
30
 
@@ -30,7 +30,7 @@ export const NON_CLIENT_EXPORTS = new Set([
30
30
  'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
31
31
  'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
32
32
  'NetworkCaptureRecord', 'NotifyInternalUsersInput', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
33
- 'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'PrincipalAllow', 'PrincipalKind',
33
+ 'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'DetectedPdfField', 'PdfFieldDetection', 'PdfFieldDetectionMethod', 'PdfFieldKind', 'PdfUnreadableError', 'PrincipalAllow', 'PrincipalKind',
34
34
  'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
35
35
  'RequestSource', 'ResolveResourceOpts', 'ResourceError', 'ResourceFieldMap',
36
36
  'ResourceOption', 'ResourcePage', 'ScheduleCadence', 'SlackChannel',
@@ -42,7 +42,7 @@ export const NON_CLIENT_EXPORTS = new Set([
42
42
  'clearClientNotifications', 'collectHeaders', 'collectXhrResponseHeaders',
43
43
  'cookielessReexchangeHeaders', 'cookiesAreBlocked', 'createAssemblyProxy',
44
44
  'createAssemblyReexchangeHandler', 'cronRoute',
45
- 'decodeBearer', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
45
+ 'decodeBearer', 'detectPdfFields', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
46
46
  'findResource', 'formatAirtableRecords', 'generateAppClient', 'getAirtableTableSchema', 'getCapturedRecords',
47
47
  'getClientAccessScope', 'getCookielessBearer', 'getDownloadUrl', 'getInstalledAppIds', 'getIntegration',
48
48
  'getInternalUserAccess', 'getLabels', 'getSessionIdentity', 'getUploadUrl',
@@ -115,8 +115,7 @@ export interface AppProxyOptions {
115
115
  *
116
116
  * `installer` calls as this app, with the same per-request mint as
117
117
  * getInstallerAssembly. `viewer` forwards the signed-in user's verified bearer,
118
- * which the platform refuses with `viewer_not_allowed` until it can prove which
119
- * person a cross-app call is for.
118
+ * which the platform scopes to that contact and refuses for a team member.
120
119
  */
121
120
  export declare function appProxyOptions(appId: string, caller: AppProxyCaller): AppProxyOptions;
122
121
  export declare function getAssemblyForRequest(req: NextRequest): Promise<AssemblyAPI | NextResponse>;
package/dist/assembly.js CHANGED
@@ -389,8 +389,7 @@ export async function getInstallerAssembly() {
389
389
  *
390
390
  * `installer` calls as this app, with the same per-request mint as
391
391
  * getInstallerAssembly. `viewer` forwards the signed-in user's verified bearer,
392
- * which the platform refuses with `viewer_not_allowed` until it can prove which
393
- * person a cross-app call is for.
392
+ * which the platform scopes to that contact and refuses for a team member.
394
393
  */
395
394
  export function appProxyOptions(appId, caller) {
396
395
  const origin = (trustedBaseUrl ?? OpenAPI.BASE).replace(/\/+$/, '');
package/dist/codegen.js CHANGED
@@ -131,10 +131,10 @@ import type { paths } from './types.js';
131
131
 
132
132
  export const APP_ID = ${JSON.stringify(appId)};
133
133
 
134
- // Calls as this app. Use it from pages, server actions, routes, cron and webhooks.
134
+ // Calls as this app. Use it for team members, cron, webhooks and background work.
135
135
  export const installerClient = createClient<paths>(appProxyOptions(APP_ID, 'installer'));
136
136
 
137
- // Calls as the signed-in person. Refused with viewer_not_allowed for now.
137
+ // Calls as the signed-in client contact. Refused for a team member.
138
138
  export const viewerClient = createClient<paths>(appProxyOptions(APP_ID, 'viewer'));
139
139
  `;
140
140
  }
@@ -155,8 +155,8 @@ const { data, error } = await installerClient.GET('/projects/{id}', {
155
155
  });
156
156
  \`\`\`
157
157
 
158
- - \`installerClient\` calls as this app. Use it from pages, server actions, routes, cron and webhooks, and filter anything you show to a client.
159
- - \`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.
158
+ - \`installerClient\` calls as this app. Use it for pages and actions a team member runs, and from cron, webhooks and background work. Filter anything you show to a client.
159
+ - \`viewerClient\` calls as the signed-in client contact. The platform scopes the call to that contact, so do not filter what it returns. Use it for pages and actions a contact runs; it is refused for a team member.
160
160
 
161
161
  ## Operations
162
162
 
package/dist/pdf.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AssemblyAPI } from '@assembly-js/node-sdk';
1
2
  export interface PdfText {
2
3
  /** Every extracted page's text, pages separated by a newline. Empty when the PDF has no text layer (a scan). */
3
4
  text: string;
@@ -9,3 +10,42 @@ export interface PdfText {
9
10
  export declare function extractPdfText(data: ArrayBuffer | Uint8Array, opts?: {
10
11
  maxPages?: number;
11
12
  }): Promise<PdfText>;
13
+ export type PdfFieldDetectionMethod = 'acroform' | 'text' | 'xfa' | 'none';
14
+ export type PdfFieldKind = 'text' | 'multiline' | 'checkbox' | 'radio' | 'dropdown' | 'date' | 'signature' | 'unknown';
15
+ export interface DetectedPdfField {
16
+ /** AcroForm field name, or a slug derived from the label for text detection. */
17
+ name: string;
18
+ /** Human label: the AcroForm name humanized, or the label the AI read. */
19
+ label: string;
20
+ kind: PdfFieldKind;
21
+ /** 1-based page number, null when it could not be resolved. */
22
+ page: number | null;
23
+ /** Radio and dropdown choices. */
24
+ options?: string[];
25
+ /** The AcroForm required flag, when the field sets it. */
26
+ required?: boolean;
27
+ source: 'acroform' | 'text';
28
+ }
29
+ export interface PdfFieldDetection {
30
+ method: PdfFieldDetectionMethod;
31
+ fields: DetectedPdfField[];
32
+ pages: number;
33
+ /** The page text when method is 'text' or 'none', so the app can run its own step. */
34
+ text?: string;
35
+ warnings: string[];
36
+ }
37
+ /** The bytes could not be parsed as a PDF; the message is safe to show to the user. */
38
+ export declare class PdfUnreadableError extends Error {
39
+ constructor(options?: {
40
+ cause?: unknown;
41
+ });
42
+ }
43
+ /**
44
+ * Finds the fields a person has to fill in on a PDF form: its AcroForm fields when
45
+ * it has them, otherwise fields read from its text by Assembly AI when `assembly`
46
+ * is given. Throws PdfUnreadableError when the bytes are not a readable PDF.
47
+ * Server side only: the AI step runs through the app's Assembly client.
48
+ */
49
+ export declare function detectPdfFields(data: ArrayBuffer | Uint8Array, options?: {
50
+ assembly?: AssemblyAPI;
51
+ }): Promise<PdfFieldDetection>;
package/dist/pdf.js CHANGED
@@ -1,15 +1,12 @@
1
- // unpdf ships a self-contained serverless build of pdf.js: the worker is inlined
2
- // and it never loads @napi-rs/canvas. pdf-parse and pdfjs-dist locate both at
3
- // runtime by paths computed from their own location, which the deploy tracer
4
- // can't follow, so they pass in the sandbox and 500 once published. Loaded
5
- // lazily so routes that never parse a PDF don't carry the bundle.
6
- export async function extractPdfText(data, opts) {
7
- const { getDocumentProxy, extractText } = await import('unpdf');
8
- // pdf.js rejects a Node Buffer outright, so hand it a plain Uint8Array view.
9
- const bytes = data instanceof ArrayBuffer
1
+ // pdf.js rejects a Node Buffer outright, so hand it a plain Uint8Array view.
2
+ function toBytes(data) {
3
+ return data instanceof ArrayBuffer
10
4
  ? new Uint8Array(data)
11
5
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
12
- const pdf = await getDocumentProxy(bytes);
6
+ }
7
+ export async function extractPdfText(data, opts) {
8
+ const { getDocumentProxy, extractText } = await import('unpdf');
9
+ const pdf = await getDocumentProxy(toBytes(data));
13
10
  try {
14
11
  const total = pdf.numPages;
15
12
  const maxPages = opts?.maxPages;
@@ -41,3 +38,268 @@ export async function extractPdfText(data, opts) {
41
38
  await pdf.loadingTask.destroy();
42
39
  }
43
40
  }
41
+ /** The bytes could not be parsed as a PDF; the message is safe to show to the user. */
42
+ export class PdfUnreadableError extends Error {
43
+ constructor(options) {
44
+ super('This file could not be opened as a PDF. It may be damaged, or not a PDF at all.', options);
45
+ this.name = 'PdfUnreadableError';
46
+ }
47
+ }
48
+ const PDF_FIELD_DETECTION_MODEL = 'gpt-4o-mini';
49
+ // A form's labels fit well inside this; the cap keeps a long PDF within one request.
50
+ const PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE = 4000;
51
+ const PDF_FIELD_DETECTION_PROMPT = [
52
+ 'You read the text of a PDF form and list the fields a person would have to fill in.',
53
+ 'Return one entry per blank, checkbox, choice or signature line, in reading order.',
54
+ 'Use the label printed next to the blank. Do not invent fields that are not in the text.',
55
+ 'page is the number from the "--- Page N ---" marker the field appears under, or null if unclear.',
56
+ 'options lists the printed choices for radio and dropdown fields, and is empty otherwise.',
57
+ ].join('\n');
58
+ const AI_CHAT_COMPLETIONS_PATH = '/v1/ai/openai/chat/completions';
59
+ const PDF_FIELD_KINDS = [
60
+ 'text',
61
+ 'multiline',
62
+ 'checkbox',
63
+ 'radio',
64
+ 'dropdown',
65
+ 'date',
66
+ 'signature',
67
+ 'unknown',
68
+ ];
69
+ const AI_RESPONSE_FORMAT = {
70
+ type: 'json_schema',
71
+ json_schema: {
72
+ name: 'pdf_form_fields',
73
+ strict: true,
74
+ schema: {
75
+ type: 'object',
76
+ additionalProperties: false,
77
+ required: ['fields'],
78
+ properties: {
79
+ fields: {
80
+ type: 'array',
81
+ items: {
82
+ type: 'object',
83
+ additionalProperties: false,
84
+ required: ['label', 'kind', 'page', 'options'],
85
+ properties: {
86
+ label: { type: 'string' },
87
+ kind: { type: 'string', enum: PDF_FIELD_KINDS },
88
+ page: { type: ['integer', 'null'] },
89
+ options: { type: 'array', items: { type: 'string' } },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ };
97
+ /**
98
+ * Finds the fields a person has to fill in on a PDF form: its AcroForm fields when
99
+ * it has them, otherwise fields read from its text by Assembly AI when `assembly`
100
+ * is given. Throws PdfUnreadableError when the bytes are not a readable PDF.
101
+ * Server side only: the AI step runs through the app's Assembly client.
102
+ */
103
+ export async function detectPdfFields(data, options) {
104
+ const bytes = toBytes(data);
105
+ const pdfLib = await import('pdf-lib');
106
+ let doc;
107
+ try {
108
+ doc = await pdfLib.PDFDocument.load(bytes, { ignoreEncryption: true });
109
+ }
110
+ catch (cause) {
111
+ throw new PdfUnreadableError({ cause });
112
+ }
113
+ const pages = doc.getPageCount();
114
+ // XFA forms look fillable in Acrobat but pdf-lib reads only AcroForm fields, so
115
+ // without this the app would silently report a fillable form as having none.
116
+ // Checked before getForm(), which strips the XFA entry from the catalog.
117
+ const hasXfa = doc.catalog.getAcroForm()?.dict.has(pdfLib.PDFName.of('XFA')) ?? false;
118
+ // pdf-lib cannot decrypt, so an encrypted form's field names and choices would come
119
+ // back as ciphertext; pdf.js can, so such a PDF goes on to text detection instead.
120
+ const warnings = doc.isEncrypted ? ['encrypted PDF: form fields read from its text instead'] : [];
121
+ // A Print or Submit button is not a field a person fills in, so a PDF whose only
122
+ // widgets are buttons still goes on to text detection.
123
+ const fields = doc.isEncrypted
124
+ ? []
125
+ : doc
126
+ .getForm()
127
+ .getFields()
128
+ .filter((field) => !(field instanceof pdfLib.PDFButton));
129
+ if (fields.length > 0)
130
+ return readAcroFormFields(doc, fields, pdfLib);
131
+ if (hasXfa)
132
+ return { method: 'xfa', fields: [], pages, warnings };
133
+ // pdf.js may detach the buffer it is handed, so it gets its own copy.
134
+ const pageTexts = await extractPageTexts(bytes.slice());
135
+ const text = pageTexts.join('\n');
136
+ if (text.trim() === '')
137
+ return { method: 'none', fields: [], pages, text, warnings };
138
+ if (!options?.assembly)
139
+ return { method: 'text', fields: [], pages, text, warnings };
140
+ const suggested = await suggestFieldsFromText(options.assembly, pageTexts);
141
+ return {
142
+ method: 'text',
143
+ fields: suggested.fields,
144
+ pages,
145
+ text,
146
+ warnings: [...warnings, ...suggested.warnings],
147
+ };
148
+ }
149
+ function readAcroFormFields(doc, fields, pdfLib) {
150
+ const pageByRef = new Map();
151
+ const pageByWidget = new Map();
152
+ const rotationByPage = new Map();
153
+ doc.getPages().forEach((page, index) => {
154
+ pageByRef.set(page.ref, index + 1);
155
+ rotationByPage.set(index + 1, ((page.getRotation().angle % 360) + 360) % 360);
156
+ // Some generators omit a widget's /P, so the page's Annots are the fallback.
157
+ const annots = page.node.Annots();
158
+ for (let i = 0; annots && i < annots.size(); i++) {
159
+ const annot = annots.lookup(i);
160
+ if (annot instanceof pdfLib.PDFDict)
161
+ pageByWidget.set(annot, index + 1);
162
+ }
163
+ });
164
+ const warnings = [];
165
+ const located = [];
166
+ for (const field of fields) {
167
+ const name = field.getName();
168
+ const widget = field.acroField.getWidgets()[0];
169
+ const pageRef = widget?.P();
170
+ const page = (pageRef && pageByRef.get(pageRef)) || (widget && pageByWidget.get(widget.dict)) || null;
171
+ if (page === null)
172
+ warnings.push(`page for field ${name} unknown`);
173
+ const rect = widget?.getRectangle() ?? { x: 0, y: 0 };
174
+ const detected = {
175
+ name,
176
+ label: humanizeFieldName(name),
177
+ kind: acroFieldKind(field, pdfLib),
178
+ page,
179
+ source: 'acroform',
180
+ };
181
+ if (field instanceof pdfLib.PDFRadioGroup ||
182
+ field instanceof pdfLib.PDFDropdown ||
183
+ field instanceof pdfLib.PDFOptionList) {
184
+ detected.options = field.getOptions();
185
+ }
186
+ if (field.isRequired())
187
+ detected.required = true;
188
+ located.push({ field: detected, ...readingPosition(rect, page ? rotationByPage.get(page) : 0) });
189
+ }
190
+ // Unknown pages go last.
191
+ located.sort((a, b) => (a.field.page ?? Infinity) - (b.field.page ?? Infinity) || a.down - b.down || a.across - b.across);
192
+ return { method: 'acroform', fields: located.map((l) => l.field), pages: doc.getPageCount(), warnings };
193
+ }
194
+ // Sort keys in the page as displayed: `down` grows top to bottom, `across` left to
195
+ // right. PDF y grows upward, and a page's /Rotate turns it clockwise for display,
196
+ // so on a landscape page rotated 90 degrees the visual top is the stored left edge.
197
+ function readingPosition(rect, rotation = 0) {
198
+ switch (rotation) {
199
+ case 90:
200
+ return { down: rect.x, across: rect.y };
201
+ case 180:
202
+ return { down: rect.y, across: -rect.x };
203
+ case 270:
204
+ return { down: -rect.x, across: -rect.y };
205
+ default:
206
+ return { down: -rect.y, across: rect.x };
207
+ }
208
+ }
209
+ function acroFieldKind(field, pdfLib) {
210
+ if (field instanceof pdfLib.PDFTextField)
211
+ return field.isMultiline() ? 'multiline' : 'text';
212
+ if (field instanceof pdfLib.PDFCheckBox)
213
+ return 'checkbox';
214
+ if (field instanceof pdfLib.PDFRadioGroup)
215
+ return 'radio';
216
+ if (field instanceof pdfLib.PDFDropdown || field instanceof pdfLib.PDFOptionList)
217
+ return 'dropdown';
218
+ if (field instanceof pdfLib.PDFSignature)
219
+ return 'signature';
220
+ return 'unknown';
221
+ }
222
+ // `form1[0].page1[0].full_name[0]` → `Full name`: the last segment is the field's
223
+ // own name, the rest is the authoring tool's container hierarchy.
224
+ function humanizeFieldName(name) {
225
+ const words = (name.split('.').pop() ?? '')
226
+ .replace(/\[\d+\]/g, '')
227
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
228
+ .replace(/[_\-\s]+/g, ' ')
229
+ .trim()
230
+ .toLowerCase();
231
+ return words ? words[0].toUpperCase() + words.slice(1) : name;
232
+ }
233
+ function slugify(label) {
234
+ return (label
235
+ .toLowerCase()
236
+ .replace(/[^a-z0-9]+/g, '_')
237
+ .replace(/^_+|_+$/g, '') || 'field');
238
+ }
239
+ async function extractPageTexts(bytes) {
240
+ const { getDocumentProxy, extractText } = await import('unpdf');
241
+ const pdf = await getDocumentProxy(bytes);
242
+ try {
243
+ const { text } = await extractText(pdf, { mergePages: false });
244
+ return text;
245
+ }
246
+ finally {
247
+ await pdf.loadingTask.destroy();
248
+ }
249
+ }
250
+ async function suggestFieldsFromText(assembly, pageTexts) {
251
+ const warnings = [];
252
+ const prompt = pageTexts
253
+ .map((pageText, index) => {
254
+ let body = pageText;
255
+ if (body.length > PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE) {
256
+ body = body.slice(0, PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE);
257
+ warnings.push(`page ${index + 1} text truncated to ${PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE} characters`);
258
+ }
259
+ return `--- Page ${index + 1} ---\n${body}`;
260
+ })
261
+ .join('\n\n');
262
+ let suggested;
263
+ try {
264
+ const response = await assembly.fetch(AI_CHAT_COMPLETIONS_PATH, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({
268
+ model: PDF_FIELD_DETECTION_MODEL,
269
+ messages: [
270
+ { role: 'system', content: PDF_FIELD_DETECTION_PROMPT },
271
+ { role: 'user', content: prompt },
272
+ ],
273
+ response_format: AI_RESPONSE_FORMAT,
274
+ stream: false,
275
+ }),
276
+ });
277
+ if (!response.ok)
278
+ throw new Error(String(response.status));
279
+ const completion = await response.json();
280
+ suggested = JSON.parse(completion.choices[0].message.content).fields;
281
+ if (!Array.isArray(suggested))
282
+ throw new Error('no fields in response');
283
+ }
284
+ catch (err) {
285
+ warnings.push(`AI suggestion failed: ${err instanceof Error ? err.message : String(err)}`);
286
+ return { fields: [], warnings };
287
+ }
288
+ const usedNames = new Set();
289
+ const fields = suggested.map((s) => {
290
+ const base = slugify(s.label);
291
+ let name = base;
292
+ for (let n = 2; usedNames.has(name); n++)
293
+ name = `${base}_${n}`;
294
+ usedNames.add(name);
295
+ const page = typeof s.page === 'number' && s.page >= 1 && s.page <= pageTexts.length ? s.page : null;
296
+ if (page === null)
297
+ warnings.push(`page for field ${name} unknown`);
298
+ const kind = PDF_FIELD_KINDS.includes(s.kind) ? s.kind : 'unknown';
299
+ const field = { name, label: s.label, kind, page, source: 'text' };
300
+ if ((kind === 'radio' || kind === 'dropdown') && s.options?.length)
301
+ field.options = s.options;
302
+ return field;
303
+ });
304
+ return { fields, warnings };
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -78,6 +78,7 @@
78
78
  "dependencies": {
79
79
  "openapi-fetch": "^0.17.0",
80
80
  "openapi-typescript": "^7.13.0",
81
+ "pdf-lib": "^1.17.1",
81
82
  "unpdf": "^1.9.1"
82
83
  }
83
84
  }
@@ -1,7 +1,7 @@
1
1
  <!-- feature:cross-app-apis -->
2
2
  ---
3
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.
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 `viewerClient` or `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
5
  ---
6
6
 
7
7
  ## Where the client lives
@@ -31,9 +31,11 @@ Call it from server code only: pages, server actions, route handlers, cron and w
31
31
 
32
32
  ## Installer or viewer
33
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.
34
+ `viewerClient` calls as the signed-in client contact. The platform scopes the call to that contact, so do not filter or double-check what it returns. Use it for pages and actions a contact runs. The platform refuses it for a team member.
35
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.
36
+ `installerClient` calls as this app, not as a person. Use it for pages and actions a team member runs, and from cron, webhooks and other background work. Filter anything you show to a client.
37
+
38
+ If an operation can be called by a contact, it must not make platform calls about clients, companies or the workspace as the caller. Contacts are refused on those routes and the call surfaces as a generic failure. Look things up with the app's own access, or store the display names on the row when the item is assigned.
37
39
 
38
40
  ## When the other app cannot answer
39
41
 
@@ -43,7 +45,7 @@ The other app can be uninstalled, can drop an operation, or can fail on its own.
43
45
  |---|---|---|
44
46
  | `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
47
  | `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`. |
48
+ | `viewer_not_allowed` | The platform refused the call for this person: a team member used `viewerClient`, or a contact could not be resolved. | For a team member, call with `installerClient`. For a contact, render an empty or permission state naming the app and never retry with `installerClient`. |
47
49
  | `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
50
 
49
51
  Branch on the result and render a state that names the app: