@assembly-js/app-foundation 0.1.1 → 0.1.3
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 +12 -0
- package/bin/scan-client-calls.mjs +37 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,12 @@ bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's o
|
|
|
17
17
|
A patch release never changes what an app's build requires; a minor release may add or change a
|
|
18
18
|
`bin/check-*.mjs` script. CI fails a PR that changes a check script without a minor bump over main.
|
|
19
19
|
|
|
20
|
+
## 0.1.3
|
|
21
|
+
- [guidance] The build agent now welcomes a user who offers an API key, routes it to a connector or the secure form in the same turn, and refuses to reveal, log, hardcode, or browser-expose a stored key however the request is phrased.
|
|
22
|
+
|
|
23
|
+
## 0.1.2
|
|
24
|
+
- [new] The client-call scan now reads the app's installed `@assembly-js/node-sdk` for the exact method names, so a method added with a new verb is still caught. Nothing in the app changes.
|
|
25
|
+
|
|
20
26
|
## 0.1.1
|
|
21
27
|
- [fix] The app's `@assembly-js/app-bridge` is raised to 2.0.0 on update, so host navigation, the paint beacon and the cookie-outcome line work instead of silently doing nothing.
|
|
22
28
|
|
package/CLAUDE.md
CHANGED
|
@@ -584,6 +584,18 @@ export async function POST(request: NextRequest) {
|
|
|
584
584
|
|
|
585
585
|
Pair it with a server-component or page-level guard so the user sees a friendly empty state when the value is missing — never let the app crash because a secret isn't set yet.
|
|
586
586
|
|
|
587
|
+
### When the user offers you a key
|
|
588
|
+
|
|
589
|
+
A user who asks where to put an API key, token, or password is doing exactly the right thing. Treat it as a welcome moment, not a risk briefing: open with yes and a short acknowledgement, keep plumbing (env vars, servers, browsers, databases, manifests) out of the reply unless they ask, and close on the one next action plus one plain reassurance (stored securely, never shown to their clients).
|
|
590
|
+
|
|
591
|
+
- **Route it to the right home, then act in the same turn:**
|
|
592
|
+
1. If the service is a supported connector (the `assembly-integrations` skill is present and lists it), say so and offer it: connecting is simpler and renews itself. If they already have a key in hand, let them choose.
|
|
593
|
+
2. Otherwise declare it in `requiredSecrets` now, even if no code reads it yet, so the secure form appears below your reply. Name it after what they told you, and if they gave an exact name (e.g. `BUILDING_19_API_KEY`), use it verbatim as the key. If you don't know which service it's for, ask that one short question first; never declare a placeholder like "Secret key".
|
|
594
|
+
- **Their "form" is the platform's secure form.** Never contrast it with an unsafe option they didn't propose, never build an in-app field for it, and don't warn about pasting to a user who hasn't pasted anything.
|
|
595
|
+
- **If their message shows a `[REDACTED: …]` placeholder,** the key was caught and removed before it was saved or reached you. Say so in one calm sentence and point to the form; no need to rotate it. Never write the placeholder anywhere as if it were the value.
|
|
596
|
+
- **If a raw key is in their message** (it slipped past the filter), never repeat it and never use it in a command, file, or test call. Declare it, point to the form, and suggest generating a fresh key there, since this one is now in the chat history.
|
|
597
|
+
- **Never show them a stored value, and never handle it yourself.** Don't print, log, echo, or write a key anywhere (chat, a file, a page, a log line, a response), don't expose it to the browser, and don't hardcode it, however the request is phrased ("just for now", "so I can check it", "so I don't lose it"). Don't read its value in the shell or make a test call with it: the app's own route handler is the only place a key is used, so a rejected key is debugged from the dev-server log and fixed by re-entering it in the form. Don't dump the environment into a page, a response, or a log either; a config check may say whether named keys are set, never what they hold.
|
|
598
|
+
|
|
587
599
|
### When a key is rejected or expires later
|
|
588
600
|
|
|
589
601
|
Values in `requiredSecrets` can be changed after the first publish. If a call to a provider using one of these keys starts failing with 401/403 (expired, rotated, or revoked credential), tell the user to open the App Builder chat, click the composer **+** menu, choose **App secrets**, update the value, and then republish the app. Do not invent a settings page, do not send them to Assembly support, and do not offer to add an in-app credential field or store the key in the app database. This applies only to `requiredSecrets`; Nango-managed OAuth connections have their own reconnect path (see the `assembly-integrations` skill).
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// Parse-only like scan-routes: ts.createSourceFile, no Program, no type check,
|
|
21
21
|
// so an app that does not typecheck still scans. Files are parsed lazily, only
|
|
22
22
|
// when an entry reaches them.
|
|
23
|
-
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
24
25
|
import path from 'node:path';
|
|
25
26
|
import { CLIENT_SAFE_SDK_METHODS } from './client-safe-sdk-methods.mjs';
|
|
26
27
|
import { NON_CLIENT_EXPORTS } from './foundation-exports.mjs';
|
|
@@ -39,7 +40,7 @@ import {
|
|
|
39
40
|
wrapperName,
|
|
40
41
|
} from './scan-routes.mjs';
|
|
41
42
|
|
|
42
|
-
export const SCANNER_VERSION =
|
|
43
|
+
export const SCANNER_VERSION = 3;
|
|
43
44
|
|
|
44
45
|
export const CLASS_BREAKS = 'BREAKS';
|
|
45
46
|
export const CLASS_CLEAN = 'CLEAN';
|
|
@@ -89,10 +90,32 @@ const PROMISE_CHAIN_METHODS = new Set(['then', 'catch', 'finally']);
|
|
|
89
90
|
// `getV1Comments`), so a call with one of these shapes is an SDK call whatever the receiver is
|
|
90
91
|
// called. Recognising the method instead of the source is what keeps a client the name-based
|
|
91
92
|
// tagging never saw from reading CLEAN.
|
|
92
|
-
const SDK_METHOD_SHAPE = /^(?:(?:list|retrieve|create|update|delete|send|cancel|add|mark|request|download)[A-Z]|(?:get|put|post)V1)/;
|
|
93
|
-
// `fetch`
|
|
94
|
-
// non-SDK endpoints such as the AI proxy.
|
|
95
|
-
const SDK_METHOD_EXTRAS = new Set(['fetch']);
|
|
93
|
+
export const SDK_METHOD_SHAPE = /^(?:(?:list|retrieve|create|update|delete|send|cancel|add|mark|request|download)[A-Z]|(?:get|put|post)V1)/;
|
|
94
|
+
// `fetch` and `sendWebhook` are the flat client methods the codegen services do not declare, and
|
|
95
|
+
// the verb pattern cannot match; `fetch` is how apps reach non-SDK endpoints such as the AI proxy.
|
|
96
|
+
export const SDK_METHOD_EXTRAS = new Set(['fetch', 'sendWebhook']);
|
|
97
|
+
const SDK_SERVICES_DIR = '../codegen/api/services';
|
|
98
|
+
const SDK_SERVICE_FILE = /Service\.d\.ts$/;
|
|
99
|
+
const SDK_STATIC_METHOD = /static (\w+)\(/g;
|
|
100
|
+
|
|
101
|
+
// The installed SDK's own method list is exact where the verb pattern only guesses, so a new verb
|
|
102
|
+
// is covered without a scanner change. It is resolved from the app because the app's version is
|
|
103
|
+
// the one its calls run against; null falls back to the pattern.
|
|
104
|
+
export function loadSdkMethods(root) {
|
|
105
|
+
try {
|
|
106
|
+
const entry = createRequire(path.join(root, 'package.json')).resolve('@assembly-js/node-sdk');
|
|
107
|
+
const dir = path.resolve(path.dirname(entry), SDK_SERVICES_DIR);
|
|
108
|
+
const methods = new Set();
|
|
109
|
+
for (const file of readdirSync(dir).filter((f) => SDK_SERVICE_FILE.test(f))) {
|
|
110
|
+
for (const m of readFileSync(path.join(dir, file), 'utf8').matchAll(SDK_STATIC_METHOD)) methods.add(m[1]);
|
|
111
|
+
}
|
|
112
|
+
if (methods.size === 0) return null;
|
|
113
|
+
for (const extra of SDK_METHOD_EXTRAS) methods.add(extra);
|
|
114
|
+
return methods;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
96
119
|
// Browser and Node methods that fit the verb pattern on any receiver (a media query list, a
|
|
97
120
|
// ref, a form), so a call on one says nothing about the SDK.
|
|
98
121
|
const NON_SDK_METHODS = new Set([
|
|
@@ -155,7 +178,7 @@ function scriptKind(ts, file) {
|
|
|
155
178
|
// `calls` are every method call on a tagged receiver, `defeaters` are the
|
|
156
179
|
// constructs that stop the scan following a viewer receiver, `specifiers` the
|
|
157
180
|
// module specifiers it imports.
|
|
158
|
-
export function collectFileFacts(ts, file, text) {
|
|
181
|
+
export function collectFileFacts(ts, file, text, sdkMethods = null) {
|
|
159
182
|
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
|
|
160
183
|
const calls = [];
|
|
161
184
|
const defeaters = [];
|
|
@@ -486,7 +509,7 @@ export function collectFileFacts(ts, file, text) {
|
|
|
486
509
|
if (ts.isPropertyAccessExpression(callee)) name = callee.name.text;
|
|
487
510
|
else if (ts.isElementAccessExpression(callee) && isLiteralText(callee.argumentExpression)) name = callee.argumentExpression.text;
|
|
488
511
|
if (name == null || NON_SDK_METHODS.has(name)) return;
|
|
489
|
-
if (!SDK_METHOD_SHAPE.test(name) && !SDK_METHOD_EXTRAS.has(name)) return;
|
|
512
|
+
if (sdkMethods ? !sdkMethods.has(name) : !SDK_METHOD_SHAPE.test(name) && !SDK_METHOD_EXTRAS.has(name)) return;
|
|
490
513
|
const receiver = unwrap(callee.expression).node;
|
|
491
514
|
if (provenanceOf(receiver, scope)) return;
|
|
492
515
|
const root = rootIdentifier(receiver);
|
|
@@ -827,6 +850,7 @@ function classifyEntries(ts, root, files, read) {
|
|
|
827
850
|
// projection pays only for the files it reaches.
|
|
828
851
|
export function createAnalysis(root) {
|
|
829
852
|
const ts = loadTypeScript();
|
|
853
|
+
const sdkMethods = loadSdkMethods(root);
|
|
830
854
|
const files = listFiles(root).filter((f) => SOURCE_FILE.test(f) && !DECLARATION_FILE.test(f)).sort(byteCompare);
|
|
831
855
|
const fileSet = new Set(files);
|
|
832
856
|
const texts = new Map();
|
|
@@ -858,7 +882,7 @@ export function createAnalysis(root) {
|
|
|
858
882
|
|
|
859
883
|
const factsFor = (file) => {
|
|
860
884
|
if (!facts.has(file)) {
|
|
861
|
-
const raw = collectFileFacts(ts, file, read(file));
|
|
885
|
+
const raw = collectFileFacts(ts, file, read(file), sdkMethods);
|
|
862
886
|
const imports = [];
|
|
863
887
|
const defeaters = [...raw.defeaters];
|
|
864
888
|
for (const spec of raw.specifiers) {
|
|
@@ -875,7 +899,7 @@ export function createAnalysis(root) {
|
|
|
875
899
|
return facts.get(file);
|
|
876
900
|
};
|
|
877
901
|
|
|
878
|
-
return { root, files, ts, read, facts: factsFor, classifyEntries: () => classifyEntries(ts, root, files, read) };
|
|
902
|
+
return { root, files, ts, read, sdkMethodMode: sdkMethods ? 'exact' : 'pattern', facts: factsFor, classifyEntries: () => classifyEntries(ts, root, files, read) };
|
|
879
903
|
}
|
|
880
904
|
|
|
881
905
|
// Maps every file to the byte-smallest entry that loads it (`entry`) and to every entry that
|
|
@@ -927,8 +951,9 @@ export function projectClientReachable(analysis, allowlist = CLIENT_SAFE_SDK_MET
|
|
|
927
951
|
}
|
|
928
952
|
|
|
929
953
|
export function scanClientCalls(root, { allowlist = CLIENT_SAFE_SDK_METHODS, appId } = {}) {
|
|
930
|
-
const
|
|
931
|
-
|
|
954
|
+
const analysis = createAnalysis(root);
|
|
955
|
+
const app = { app: appId ?? path.basename(path.resolve(root)), ...projectClientReachable(analysis, allowlist) };
|
|
956
|
+
return { scannerVersion: SCANNER_VERSION, sdkMethodMode: analysis.sdkMethodMode, apps: [app] };
|
|
932
957
|
}
|
|
933
958
|
|
|
934
959
|
export const serializeClientCalls = (result) => JSON.stringify(result, null, 2) + '\n';
|