@assembly-js/app-foundation 0.11.0 → 0.13.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,14 @@ 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.13.0
23
+ - [new] `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client` render a name that opens the record's CRM page for staff and is plain text for a client viewer.
24
+ - [new] `bun run build` runs `check:linked-names`, which fails when a client's or company's name is rendered by hand instead of through those components. It only runs on apps that carry the platform-links skill.
25
+ - [guidance] `assembly-platform-links` leads with rendering client and company names through the components.
26
+
27
+ ## 0.12.0
28
+ - [behavior] `check:crons` on a platform update turn now only warns about a cron route the app already had without a schedule; a route added this turn, or one that lost its schedule, still fails.
29
+
22
30
  ## 0.11.0
23
31
  - [behavior] `check:record-urls` now checks each surface on its own: a record opened on the client portal needs its own `/c` route or `useUrlState`, and an internal dynamic route no longer exempts it. It also catches a list/detail view switch held in `useState`, so a client's request page keeps its own URL that can be reloaded or shared.
24
32
  - [guidance] `assembly-app-inner-location` says the record URL rule applies to the client portal too, with `app/c/[id]/page.tsx` or `?id=` through `useUrlState` as the two valid shapes.
@@ -16,7 +16,8 @@
16
16
  // as warnings: those paths have no agent who can act on a failure, so failing
17
17
  // them would only strand an app that already worked. A below-floor schedule
18
18
  // the app shipped before the turn stays a warning even then (see
19
- // baselineCronSchedules).
19
+ // baselineCronSchedules), as does a cron route the app shipped with no schedule
20
+ // at all (see baselineUnscheduledRoutes).
20
21
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
21
22
  import { spawnSync } from 'node:child_process';
22
23
  import { join, relative, sep } from 'node:path';
@@ -42,7 +43,8 @@ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
42
43
  // Set by the turn program on a platform update turn, where the update itself is
43
44
  // the mandate to fix whatever is wrong, so findings fail the build whether or
44
45
  // not the turn touched the cron wiring.
45
- const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
46
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
47
+ const STRICT = process.argv.includes('--strict') || UPDATE_TURN;
46
48
  const CRON_WIRING_PATHS = ['vercel.json', 'app/api/cron/'];
47
49
 
48
50
  function report(lines, { fatal }) {
@@ -92,6 +94,44 @@ function baselineCronSchedules() {
92
94
  return schedules;
93
95
  }
94
96
 
97
+ // Cron route files (repo-relative, posix) that existed at the turn baseline with
98
+ // no vercel.json entry invoking them. An update turn has no mandate to schedule
99
+ // work the app never ran, so these only warn. An unreadable baseline yields an
100
+ // empty set, which keeps every unscheduled route fatal.
101
+ function baselineUnscheduledRoutes() {
102
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
103
+ const unscheduled = new Set();
104
+ if (!baseline || !UPDATE_TURN) return unscheduled;
105
+ const listing = spawnSync('git', ['ls-tree', '-r', '--name-only', baseline, '--', 'app'], {
106
+ cwd: ROOT,
107
+ encoding: 'utf8',
108
+ });
109
+ if (listing.status !== 0) return unscheduled;
110
+ // Route groups can hold cron routes too, so list all of app/ and filter by the URL served.
111
+ const routePaths = listing.stdout
112
+ .split('\n')
113
+ .filter((p) => ROUTE_FILE.test(p.split('/').pop()))
114
+ .filter((p) => ('/' + urlSegments(p.split('/').slice(1)).join('/') + '/').startsWith(CRON_ROUTE_PREFIX));
115
+ const shown = spawnSync('git', ['show', `${baseline}:vercel.json`], { cwd: ROOT, encoding: 'utf8' });
116
+ let baselineCrons = [];
117
+ if (shown.status === 0) {
118
+ try {
119
+ const parsed = JSON.parse(shown.stdout).crons;
120
+ if (Array.isArray(parsed)) baselineCrons = parsed;
121
+ } catch {
122
+ // An unparseable baseline file scheduled nothing.
123
+ }
124
+ }
125
+ const scheduledPaths = baselineCrons
126
+ .filter((c) => typeof c?.path === 'string')
127
+ .map((c) => c.path.replace(/\/+$/, '').split('/').filter(Boolean));
128
+ for (const path of routePaths) {
129
+ const segments = urlSegments(path.split('/').slice(1));
130
+ if (!scheduledPaths.some((pathSegments) => routeServes(segments, pathSegments))) unscheduled.add(path);
131
+ }
132
+ return unscheduled;
133
+ }
134
+
95
135
  function cronKey(entry) {
96
136
  return `${entry?.path}\n${typeof entry?.schedule === 'string' ? entry.schedule.trim() : ''}`;
97
137
  }
@@ -146,8 +186,11 @@ function routeFiles(dir) {
146
186
  // The URL segments a route file serves; route groups `(group)` and parallel
147
187
  // slots `@slot` never appear in the URL.
148
188
  function urlSegmentsForRouteFile(file) {
149
- return relative(APP_DIR, file)
150
- .split(sep)
189
+ return urlSegments(relative(APP_DIR, file).split(sep));
190
+ }
191
+
192
+ function urlSegments(pathParts) {
193
+ return pathParts
151
194
  .slice(0, -1)
152
195
  .filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
153
196
  }
@@ -259,6 +302,7 @@ const entries = Array.isArray(crons) ? crons : [];
259
302
  const coveredRoutes = new Set();
260
303
  const shippedSchedules = baselineCronSchedules();
261
304
  const preexistingIntervals = [];
305
+ const unscheduledAtBaseline = baselineUnscheduledRoutes();
262
306
 
263
307
  entries.forEach((entry, i) => {
264
308
  const label = `vercel.json crons[${i}]`;
@@ -296,6 +340,14 @@ entries.forEach((entry, i) => {
296
340
  for (const route of cronRoutes) {
297
341
  const url = '/' + route.segments.join('/');
298
342
  if (!coveredRoutes.has(route.file)) {
343
+ const rel = relative(ROOT, route.file).split(sep).join('/');
344
+ if (unscheduledAtBaseline.has(rel)) {
345
+ preexistingIntervals.push(
346
+ `${rel} serves ${url} but no vercel.json cron entry invokes it. The app shipped this route unscheduled, ` +
347
+ `and the update does not add a schedule, so leave it as it is.`,
348
+ );
349
+ continue;
350
+ }
299
351
  problems.push(
300
352
  `${relative(ROOT, route.file)} serves ${url} but no vercel.json cron entry invokes it — it will never run ` +
301
353
  `after publish. Add { "path": "${url}", "schedule": "*/30 * * * *" } to "crons" or remove the route.`,
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a client's or company's name is rendered by hand in a
3
+ // component instead of through `<ClientName>` / `<CompanyName>` from
4
+ // `@assembly-js/app-foundation/lib-client`. The components link the name to the
5
+ // record's CRM page for staff and render plain text for a client viewer; a
6
+ // hand-rendered name never links, and the prose guidance to call `linkTo` was
7
+ // not followed. The match favours precision, because a false positive blocks the
8
+ // build: it only looks at a name placed directly in JSX, or a full name built
9
+ // from `X.givenName` and `X.familyName`, where the object is called `client`
10
+ // or `customer` (and `company` for the company name).
11
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
12
+ // any deps beyond Node are guaranteed.
13
+ //
14
+ // Inert unless the platform-links skill was written into the app (it ships only when
15
+ // the deep-link flag is on) and the app depends on the foundation package, which is
16
+ // where the components live: a vendored app has nothing to switch to.
17
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
18
+ // update never rewrites how an app renders names.
19
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
20
+ import { join, relative, sep } from 'node:path';
21
+
22
+ const ROOT = process.cwd();
23
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
24
+ const PLATFORM_LINKS_SKILL = join(ROOT, '.claude', 'skills', 'assembly-platform-links', 'SKILL.md');
25
+ const SOURCE_FILE = /\.(tsx|jsx)$/;
26
+ const PACKAGE_JSON = join(ROOT, 'package.json');
27
+ const OPT_OUT = /linked-names:\s*plain/;
28
+
29
+ const OBJECT_PATH = String.raw`(?:[\w$]+\??\.)*`;
30
+ const FALLBACK = String.raw`(?:\s*(?:\?\?|\|\|)\s*(?:'[^']*'|"[^"]*"))?`;
31
+ // `{client.givenName}` placed in JSX, not an attribute value (`=`) or a template (`$`).
32
+ const CLIENT_NAME_CHILD = new RegExp(
33
+ String.raw`(?<!=\s*)(?<!\$)\{\s*${OBJECT_PATH}(?:client|customer)\??\.(?:givenName|familyName)${FALLBACK}\s*\}`,
34
+ 'gi',
35
+ );
36
+ const COMPANY_NAME_CHILD = new RegExp(
37
+ String.raw`(?<!=\s*)(?<!\$)\{\s*${OBJECT_PATH}company\??\.name${FALLBACK}\s*\}`,
38
+ 'gi',
39
+ );
40
+ // `${client.givenName} ${client.familyName}`, `[client.givenName, client.familyName].join(' ')`
41
+ // and `client.givenName + ' ' + client.familyName`; a lone `[0]` initial is not a name.
42
+ const FULL_NAME = new RegExp(
43
+ String.raw`(?:` +
44
+ String.raw`\$\{\s*${OBJECT_PATH}(?:client|customer)\??\.givenName\s*\}[^\`]{0,20}\$\{\s*${OBJECT_PATH}(?:client|customer)\??\.familyName\s*\}` +
45
+ String.raw`|\[\s*${OBJECT_PATH}(?:client|customer)\??\.givenName\s*,\s*${OBJECT_PATH}(?:client|customer)\??\.familyName\s*\]` +
46
+ String.raw`|${OBJECT_PATH}(?:client|customer)\??\.givenName\s*\+\s*['"\`][^'"\`]*['"\`]\s*\+\s*${OBJECT_PATH}(?:client|customer)\??\.familyName` +
47
+ String.raw`)`,
48
+ 'gi',
49
+ );
50
+
51
+ function sourceFiles(dir) {
52
+ const out = [];
53
+ if (!existsSync(dir)) return out;
54
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
+ const full = join(dir, entry.name);
56
+ if (entry.isDirectory()) {
57
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
58
+ out.push(...sourceFiles(full));
59
+ } else if (SOURCE_FILE.test(entry.name)) {
60
+ out.push(full);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // An attribute value (`alt={...}`, `title={`...`}`) is not rendered text.
67
+ function inAttribute(src, index) {
68
+ return /=\s*\{\s*`?$/.test(src.slice(Math.max(0, index - 16), index));
69
+ }
70
+
71
+ // A built string that is not rendered as JSX text (an object label, a filter key) cannot
72
+ // be a component; only a name sitting in text, or returned/assigned for display, is flagged.
73
+ function inStringOnlyUse(src, index) {
74
+ const lineStart = src.lastIndexOf('\n', index) + 1;
75
+ return /(?:\blabel|\bvalue|\bkey|\bsearch\w*|\bfilter\w*|\bquery)\s*[:=]\s*[^\n]*$/i.test(src.slice(lineStart, index));
76
+ }
77
+
78
+ function lineOf(src, index) {
79
+ return src.slice(0, index).split('\n').length;
80
+ }
81
+
82
+ if (!existsSync(PLATFORM_LINKS_SKILL)) process.exit(0);
83
+ try {
84
+ if (!readFileSync(PACKAGE_JSON, 'utf8').includes('app-foundation')) process.exit(0);
85
+ } catch {
86
+ process.exit(0);
87
+ }
88
+
89
+ const violations = [];
90
+ for (const file of [...sourceFiles(join(ROOT, 'app')), ...sourceFiles(join(ROOT, 'components'))]) {
91
+ const src = readFileSync(file, 'utf8');
92
+ if (OPT_OUT.test(src)) continue;
93
+ const where = relative(ROOT, file).split(sep).join('/');
94
+ const found = new Map();
95
+ for (const [re, kind] of [
96
+ [CLIENT_NAME_CHILD, 'client name'],
97
+ [FULL_NAME, 'client name'],
98
+ [COMPANY_NAME_CHILD, 'company name'],
99
+ ]) {
100
+ for (const m of src.matchAll(re)) if (!inAttribute(src, m.index) && !(re === FULL_NAME && inStringOnlyUse(src, m.index))) found.set(m.index, `${where}:${lineOf(src, m.index)} (${kind}: ${m[0].trim()})`);
101
+ }
102
+ violations.push(...[...found.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v));
103
+ }
104
+
105
+ if (violations.length > 0 && UPDATE_TURN) {
106
+ console.warn('');
107
+ console.warn('check-linked-names: names rendered by hand (this update turn is not failed for it):');
108
+ for (const v of violations) console.warn(' - ' + v);
109
+ console.warn('');
110
+ } else if (violations.length > 0) {
111
+ console.error('');
112
+ console.error('A client or company name is rendered by hand, so it never links to its record:');
113
+ console.error('');
114
+ for (const v of violations) console.error(' - ' + v);
115
+ console.error('');
116
+ console.error("Render it with the components from '@assembly-js/app-foundation/lib-client':");
117
+ console.error(' <ClientName client={client} className="..." /> // a client with givenName/familyName');
118
+ console.error(' <CompanyName company={company} className="..." /> // a company with name');
119
+ console.error('Staff get a link to the CRM record; a client viewer gets plain text.');
120
+ console.error('A name that must stay plain (the viewer\'s own greeting, a title attribute) can add the');
121
+ console.error('comment `// linked-names: plain` to the file.');
122
+ console.error('A string-only use (a `label:` or `filter` value) is not flagged.');
123
+ process.exit(1);
124
+ }
@@ -1,4 +1,6 @@
1
+ import type { ReactElement } from 'react';
1
2
  import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
3
+ import { type ClientNameRecord, type CompanyNameRecord } from './record-names.js';
2
4
  export declare function resetRenewalStateForTests(): void;
3
5
  export declare function reexchangeBearer(): Promise<boolean>;
4
6
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -28,3 +30,22 @@ export declare function handleVisibilityChangeForTests(): void;
28
30
  * </button>
29
31
  */
30
32
  export declare function linkTo(destination: NavigateDestination, options?: NavigateToOptions): () => void;
33
+ interface RecordNameProps {
34
+ className?: string;
35
+ viewer?: 'internal' | 'client';
36
+ }
37
+ /**
38
+ * A client's name that opens their CRM record for staff and is plain text for a
39
+ * client viewer, who cannot reach the destination.
40
+ *
41
+ * @example
42
+ * <ClientName client={client} className="font-medium" />
43
+ */
44
+ export declare function ClientName({ client, className, viewer }: RecordNameProps & {
45
+ client: ClientNameRecord;
46
+ }): ReactElement;
47
+ /** A company's name that opens its CRM record for staff and is plain text for a client viewer. */
48
+ export declare function CompanyName({ company, className, viewer }: RecordNameProps & {
49
+ company: CompanyNameRecord;
50
+ }): ReactElement;
51
+ export {};
@@ -5,7 +5,10 @@
5
5
  // never be dragged into the Edge middleware bundle that imports ./client
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
+ import { createElement } from 'react';
9
+ import { usePathname } from 'next/navigation';
8
10
  import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
11
+ import { clientDisplayName, isClientSurfacePath, } from './record-names.js';
9
12
  import { readBearerExpiry, readCookie } from './origin-bootstrap.js';
10
13
  import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
11
14
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
@@ -338,3 +341,43 @@ export function linkTo(destination, options) {
338
341
  });
339
342
  };
340
343
  }
344
+ function isClientViewer(viewer, pathname) {
345
+ return viewer ? viewer === 'client' : isClientSurfacePath(pathname);
346
+ }
347
+ // A button so the link is keyboard reachable, reset to read as inline text; the
348
+ // colour is only inherited when the app gave no class, so its own colour wins.
349
+ // createElement keeps the package free of a jsx compiler setting.
350
+ function recordName(name, destination, className) {
351
+ if (!destination)
352
+ return createElement('span', { className }, name);
353
+ return createElement('button', {
354
+ type: 'button',
355
+ className,
356
+ onClick: linkTo(destination),
357
+ style: {
358
+ background: 'none',
359
+ border: 0,
360
+ padding: 0,
361
+ font: 'inherit',
362
+ textAlign: 'inherit',
363
+ cursor: 'pointer',
364
+ color: className ? undefined : 'inherit',
365
+ },
366
+ }, name);
367
+ }
368
+ /**
369
+ * A client's name that opens their CRM record for staff and is plain text for a
370
+ * client viewer, who cannot reach the destination.
371
+ *
372
+ * @example
373
+ * <ClientName client={client} className="font-medium" />
374
+ */
375
+ export function ClientName({ client, className, viewer }) {
376
+ const isClient = isClientViewer(viewer, usePathname());
377
+ return recordName(clientDisplayName(client), isClient ? null : { to: 'client', clientId: client.id }, className);
378
+ }
379
+ /** A company's name that opens its CRM record for staff and is plain text for a client viewer. */
380
+ export function CompanyName({ company, className, viewer }) {
381
+ const isClient = isClientViewer(viewer, usePathname());
382
+ return recordName(company.name ?? '', isClient ? null : { to: 'company', companyId: company.id }, className);
383
+ }
@@ -0,0 +1,16 @@
1
+ export interface ClientNameRecord {
2
+ id: string;
3
+ givenName?: string | null;
4
+ familyName?: string | null;
5
+ }
6
+ export interface CompanyNameRecord {
7
+ id: string;
8
+ name?: string | null;
9
+ }
10
+ export declare function clientDisplayName(client: ClientNameRecord): string;
11
+ /**
12
+ * Whether the page is rendered for a client in the portal. `client` and `company`
13
+ * are staff-only destinations, so an unknown surface also counts as a client:
14
+ * plain text is the safe failure, a dead link is not.
15
+ */
16
+ export declare function isClientSurfacePath(pathname: string): boolean;
@@ -0,0 +1,15 @@
1
+ import { getSurfacePrefix } from './location-prefix.js';
2
+ // The notification surface is staff-facing, so only the other prefixed surface is the client portal.
3
+ const NOTIFICATION_PREFIX = '/n';
4
+ export function clientDisplayName(client) {
5
+ return [client.givenName, client.familyName].filter(Boolean).join(' ');
6
+ }
7
+ /**
8
+ * Whether the page is rendered for a client in the portal. `client` and `company`
9
+ * are staff-only destinations, so an unknown surface also counts as a client:
10
+ * plain text is the safe failure, a dead link is not.
11
+ */
12
+ export function isClientSurfacePath(pathname) {
13
+ const prefix = getSurfacePrefix(pathname);
14
+ return prefix !== '' && prefix !== NOTIFICATION_PREFIX;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -22,6 +22,10 @@ If it reports `unsupported`, upgrade the app in this same turn instead of skippi
22
22
  4. Run `bunx tsc --noEmit` and `bun run build`, and confirm the existing header and toast bridge calls still type-check. Fix any break before continuing.
23
23
  5. Re-run the check above, then proceed with this skill.
24
24
 
25
+ ## Client and company names: use the components
26
+
27
+ Render every client or company name with `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client`, in place of `{client.givenName} {client.familyName}` or `{company.name}`. They take the record objects the app already fetches and accept `className`. Staff get a link to the record's CRM page; a client viewer gets plain text. `bun run build` fails `check:linked-names` for a name rendered by hand. The rest of this skill covers other destinations.
28
+
25
29
  ## When to reach for this skill
26
30
 
27
31
  Any time the app renders a **name or reference to a platform entity** — a client name, a company badge, an invoice number, a contract, a form, a file — offer a link that opens the platform's canonical detail page, on surfaces where that destination is reachable (see the "Client portal" column in the destination catalog below). Internal/staff users can navigate to any destination. Clients in the portal can only reach the destinations marked available there — their own invoices, contracts, help articles, files, channels, and other installed apps — not `client`, `company`, `form`, or the other staff-only destinations, which the host rejects for a client viewer.