@colixsystems/widget-sdk 0.63.0 → 0.65.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/README.md +19 -4
- package/dist/contract.cjs +60 -19
- package/dist/contract.js +60 -19
- package/dist/datetimepicker-format.js +22 -3
- package/dist/datetimepicker.js +14 -3
- package/dist/datetimepicker.native.js +15 -3
- package/dist/hooks.js +17 -3
- package/dist/linter.cjs +52 -17
- package/dist/linter.js +62 -24
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -53,7 +53,21 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
53
53
|
|
|
54
54
|
## Status
|
|
55
55
|
|
|
56
|
-
`v0.
|
|
56
|
+
`v0.65.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
57
|
+
|
|
58
|
+
### What's new in 0.65.0
|
|
59
|
+
|
|
60
|
+
**Every file record carries `url`, and hand-built file URLs are linted (sc-3589 follow-up).** Two fixes for the same real-world failure: a `FILE` column's image silently not rendering.
|
|
61
|
+
|
|
62
|
+
- **`url` is now on every file record**, aliasing the absolutized `presigned_url`. The wire field is `presigned_url`, so the intuitive `file.url` read was `undefined` — and because the usual guard is `if (!file.url) return null`, the widget rendered *nothing*, with no error to trace. The alias is added in the filestore client's one shared normalizer, so it applies to `useFilestoreFile(id)` **and every row of `useFilestoreFiles`** — a gallery can render `files.map(f => f.url)` directly. `presigned_url` is unchanged and still present. Requires `@colixsystems/filestore-client` ≥ 0.7.0.
|
|
63
|
+
- **Prefer the top-level `url`:** `const { url } = useFilestoreFile(id)`. The returned `file` is `null` until the fetch resolves (and stays null for an empty `FILE` cell), so `const { file } = …; file.url` throws on the first render. `file.url` is correct only *after* you null-check `file`.
|
|
64
|
+
- **`no-host-api-url` now flags hand-built host paths.** The needles were only `/api/v1`, `/uploads/` and `Authorization: Bearer`, so `` `/api/files/${id}` `` — a route that does not exist — passed clean and shipped. The rule now matches `/api/files/` anywhere (so the origin-prefixed `` `${location.origin}/api/files/${id}` `` is caught too) plus a *quoted* relative `/api/` path for invented prefixes generally. Two deliberate carve-outs: **comments are not scanned**, so documenting the rule in a comment is safe; and an absolute third-party URL that merely contains `/api/` (`https://api.example.com/api/x`) never matches. If a third-party call genuinely needs a *relative* `/api/…` path against an axios `baseURL`, add `// appstudio-lint-ignore no-host-api-url` on that line or the line above.
|
|
65
|
+
|
|
66
|
+
**Never build a file URL from an id.** Filestore bytes are only reachable through a server-signed token URL, so a client-composed path can never work — always go through `useFilestoreFile`. `CONTRACT.version` → `1.44.0`. Both changes additive; no export changed signature.
|
|
67
|
+
|
|
68
|
+
### What's new in 0.64.0
|
|
69
|
+
|
|
70
|
+
**`<DateTimePicker>` themes itself — legible on dark surfaces (sc-3370).** The primitive now derives its colours from the workspace theme on BOTH hosts instead of hardcoding them: the text uses `colors.onSurface`, the border uses `colors.border`, and on web the input's `color-scheme` follows the theme so the browser's built-in date UI (the `yyyy-mm-dd` edit segments and the calendar icon) stays legible on a dark surface. Previously the web input used `color: inherit` with no `color-scheme` and the native trigger set no text colour, so on a dark-themed app the field rendered dark-on-dark and unreadable — with no prop an author could set to fix it. **No prop changed**: the seven-prop contract (`value, onChange, mode, minimumDate, maximumDate, disabled, accessibilityLabel`) is unchanged, and you do NOT style the field yourself — never reach for raw CSS or `document`, which the widget linter rejects. `CONTRACT.version` → `1.43.0`. Behavioural fix, additive.
|
|
57
71
|
|
|
58
72
|
### What's new in 0.63.0
|
|
59
73
|
|
|
@@ -230,8 +244,8 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
|
|
|
230
244
|
### What's new in 0.30.0
|
|
231
245
|
|
|
232
246
|
**Filestore browsing + BankID file signing for widgets (REQ-FS / REQ-SIGN).** Three new hooks read a newly-injected `ctx.filestore` (the `@colixsystems/filestore-client`, now constructed by both the web and native hosts):
|
|
233
|
-
- `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space.
|
|
234
|
-
- `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. Requires `files.read:*`.
|
|
247
|
+
- `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space. Every row carries a ready-to-render `url` (its absolutized `presigned_url`), so a gallery renders `files.map(f => f.url)` directly — no per-row `useFilestoreFile` call.
|
|
248
|
+
- `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. **Read the top-level `url`** — the returned `file` is `null` until the fetch resolves (and for an empty id), so `file.url` throws on the first render; it is correct only after a null check. **Never compose a file URL yourself** — the bytes are served only from a server-signed token URL, so a hand-built path like `/api/files/<id>` can never resolve (the linter's `no-host-api-url` rule flags it, including the `${location.origin}/api/files/…` form). A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. When you render `url` in an `<Image>` that fills its container, size it with `aspectRatio` (e.g. `{ width: "100%", aspectRatio: 1 }`) or pixels — never a percentage `height`, which React Native collapses to 0 against a content-sized parent, so the image loads but is invisible. Requires `files.read:*`.
|
|
235
249
|
- `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
|
|
236
250
|
- `useFilestoreUpload({ spaceType, folderId? })` → `{ upload, uploading, error, lastUploaded }` — POSTs a multipart upload to `ctx.filestore.files.upload`. Resolves `owner_id` from the host context (like the read hooks) so the widget only picks the space + destination folder. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
|
|
237
251
|
- `useFileSignature(fileId)` → `{ status, qr, signerName, verdict, initiate, refresh, cancel, verify, … }` — drives a BankID signing flow for a file (the backend hashes the bytes server-side, binds the digest into the signature, and verifies the proof offline).
|
|
@@ -485,8 +499,9 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
485
499
|
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
486
500
|
- **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`.
|
|
487
501
|
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
|
|
502
|
+
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the native-safe way to go multi-column (widgets have no breakpoint hook, so never hard-code fixed columns). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
|
|
488
503
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
489
|
-
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme.
|
|
504
|
+
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
|
490
505
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
|
|
491
506
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
492
507
|
|
package/dist/contract.cjs
CHANGED
|
@@ -211,10 +211,14 @@ const HOOKS = [
|
|
|
211
211
|
name: "useFilestoreFiles",
|
|
212
212
|
signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
|
|
213
213
|
description:
|
|
214
|
-
"Browse the end-user's Filestore files in a project or
|
|
215
|
-
"The hook resolves owner_id from the host context (tenant for
|
|
216
|
-
"app user for personal) — the widget only chooses the
|
|
217
|
-
"
|
|
214
|
+
"Browse the end-user's Filestore files in a project, personal, or public " +
|
|
215
|
+
"space. The hook resolves owner_id from the host context (tenant for " +
|
|
216
|
+
"project/public, app user for personal) — the widget only chooses the " +
|
|
217
|
+
"space. A public space is world-readable: its files render even for " +
|
|
218
|
+
"logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
|
|
219
|
+
"{ data, meta } to the files array. Every row carries a ready-to-render " +
|
|
220
|
+
"`url` (its absolutized presigned_url, aliased by the filestore client) " +
|
|
221
|
+
"so a gallery renders `files.map(f => f.url)` directly.",
|
|
218
222
|
returnShape: {
|
|
219
223
|
files: "FilestoreFile[]",
|
|
220
224
|
loading: "boolean",
|
|
@@ -236,7 +240,12 @@ const HOOKS = [
|
|
|
236
240
|
"absolutized by the client so it loads on web AND native. An empty id " +
|
|
237
241
|
"collapses to { file: null, url: null } with no round-trip; a deleted / " +
|
|
238
242
|
"not-found id degrades the same way (url stays null, error carries the " +
|
|
239
|
-
"wire error) so a display widget shows its fallback instead of crashing."
|
|
243
|
+
"wire error) so a display widget shows its fallback instead of crashing. " +
|
|
244
|
+
"READ THE TOP-LEVEL `url`: `file` is null until the fetch resolves (and " +
|
|
245
|
+
"stays null for an empty id), so `file.url` throws on the first render. " +
|
|
246
|
+
"Every file record the client returns does carry the same `url` alias, " +
|
|
247
|
+
"so `file.url` is correct once you have null-checked `file`. " +
|
|
248
|
+
"NEVER build a file URL by hand from an id — no route serves one.",
|
|
240
249
|
returnShape: {
|
|
241
250
|
file: "FilestoreFile | null",
|
|
242
251
|
url: "string | null",
|
|
@@ -295,10 +304,10 @@ const HOOKS = [
|
|
|
295
304
|
name: "useFilestoreFolders",
|
|
296
305
|
signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
|
|
297
306
|
description:
|
|
298
|
-
"Browse the end-user's Filestore folders in a project or
|
|
299
|
-
"mirroring useFilestoreFiles for subfolder navigation. The hook
|
|
300
|
-
"owner_id from the host context; pass enabled:false to suspend
|
|
301
|
-
"Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
307
|
+
"Browse the end-user's Filestore folders in a project, personal, or public " +
|
|
308
|
+
"space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
|
|
309
|
+
"resolves owner_id from the host context; pass enabled:false to suspend " +
|
|
310
|
+
"fetching. Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
302
311
|
returnShape: {
|
|
303
312
|
folders: "FilestoreFolder[]",
|
|
304
313
|
loading: "boolean",
|
|
@@ -823,7 +832,7 @@ const PRIMITIVES = [
|
|
|
823
832
|
{
|
|
824
833
|
name: "DateTimePicker",
|
|
825
834
|
description:
|
|
826
|
-
'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native `<input type="date|time|datetime-local">` directly (react-native-web has no mapping for the RN datetimepicker library); native uses @react-native-community/datetimepicker.',
|
|
835
|
+
'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native `<input type="date|time|datetime-local">` directly (react-native-web has no mapping for the RN datetimepicker library); native uses @react-native-community/datetimepicker. The field themes itself from the workspace theme — text uses `colors.onSurface`, the border `colors.border`, and on web `color-scheme` follows the theme so the browser\'s built-in date UI stays legible on a dark surface; never style it with raw CSS or `document`.',
|
|
827
836
|
rnComponent: "@react-native-community/datetimepicker",
|
|
828
837
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
829
838
|
},
|
|
@@ -1090,7 +1099,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1090
1099
|
},
|
|
1091
1100
|
filestore: {
|
|
1092
1101
|
description:
|
|
1093
|
-
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal spaces) + BankID file signing. " +
|
|
1102
|
+
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal / public spaces) + BankID file signing. " +
|
|
1094
1103
|
"{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), remove(id), preview(id) }, " +
|
|
1095
1104
|
"folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
|
|
1096
1105
|
"signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
|
|
@@ -1209,12 +1218,11 @@ const BUNDLE_EXPORT_CONTRACT = [
|
|
|
1209
1218
|
// REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
|
|
1210
1219
|
// §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
|
|
1211
1220
|
// third-party APIs directly. Same-origin requests to the host's own
|
|
1212
|
-
// `/api/*` surface
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
// submission so authors learn the rule statically.
|
|
1221
|
+
// `/api/*` surface just fail: widget code never receives the JWT, so they
|
|
1222
|
+
// 401 (and an invented route like `/api/files/<id>` 404s). There is no
|
|
1223
|
+
// runtime gate — the soft linter warning (`no-host-api-url`) is the only
|
|
1224
|
+
// thing that catches it, which is why it flags relative `/api/` literals
|
|
1225
|
+
// too, not just the real `/api/v1` prefix.
|
|
1218
1226
|
const BANNED_APIS = [
|
|
1219
1227
|
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
1220
1228
|
{
|
|
@@ -1305,7 +1313,7 @@ const VETTED_IMPORTS = [
|
|
|
1305
1313
|
platforms: ["web", "native"],
|
|
1306
1314
|
category: "network",
|
|
1307
1315
|
description:
|
|
1308
|
-
"HTTP client for third-party APIs. Calls to the host's /api/* surface
|
|
1316
|
+
"HTTP client for third-party APIs. Calls to the host's own /api/* surface do not work — a widget is never given a JWT, so they 401 (and an invented path matches no route at all); use SDK hooks for workspace data.",
|
|
1309
1317
|
},
|
|
1310
1318
|
{
|
|
1311
1319
|
specifier: "date-fns",
|
|
@@ -1513,6 +1521,18 @@ const HOST_API_URL_PATTERNS = [
|
|
|
1513
1521
|
"/api/v1",
|
|
1514
1522
|
"/uploads/",
|
|
1515
1523
|
"Authorization: Bearer",
|
|
1524
|
+
// sc-3589 follow-up — the invented file route, matched ANYWHERE in a literal
|
|
1525
|
+
// so the origin-prefixed spelling (`${location.origin}/api/files/<id>`) is
|
|
1526
|
+
// caught as well as the bare relative one. No route serves this path.
|
|
1527
|
+
"/api/files/",
|
|
1528
|
+
// A QUOTED relative path into the host API — a broader net for invented
|
|
1529
|
+
// prefixes generally. Quote-anchored so an absolute third-party URL that
|
|
1530
|
+
// merely contains "/api/" (https://api.example.com/api/x) does not match.
|
|
1531
|
+
// Comments are blanked before matching, and a legitimate third-party
|
|
1532
|
+
// relative path can opt out with the `appstudio-lint-ignore` directive.
|
|
1533
|
+
'"/api/',
|
|
1534
|
+
"'/api/",
|
|
1535
|
+
"`/api/",
|
|
1516
1536
|
];
|
|
1517
1537
|
|
|
1518
1538
|
function deepFreeze(value) {
|
|
@@ -1919,7 +1939,28 @@ const CONTRACT = deepFreeze({
|
|
|
1919
1939
|
// default false): declares that the widget renders its own section header,
|
|
1920
1940
|
// so the app-builder places it as a whole section without stacking a
|
|
1921
1941
|
// heading above it. Additive — existing manifests omit it and read false.
|
|
1922
|
-
|
|
1942
|
+
// 1.43.0 (sc-3370) — the `<DateTimePicker>` primitive now themes itself from
|
|
1943
|
+
// the workspace theme on both hosts: text uses `colors.onSurface`, the
|
|
1944
|
+
// border `colors.border`, and the web input's `color-scheme` follows the
|
|
1945
|
+
// theme so the browser's built-in date UI stays legible on a dark surface.
|
|
1946
|
+
// Previously it hardcoded `color: inherit` / an unset scheme (web) and an
|
|
1947
|
+
// uncoloured trigger (native), rendering dark-on-dark. No prop changed —
|
|
1948
|
+
// behavioural fix, additive.
|
|
1949
|
+
// 1.44.0 (sc-3589 follow-up) — two FILE-display fixes. (a) The filestore
|
|
1950
|
+
// client's `withDisplayableUrl` normalizer now aliases the absolutized presigned_url as `url` on
|
|
1951
|
+
// EVERY file record, so `file.url` works for `useFilestoreFile` AND every
|
|
1952
|
+
// row of `useFilestoreFiles`. The wire field is `presigned_url`; reading
|
|
1953
|
+
// the wrong name returned undefined silently, rendering nothing with no
|
|
1954
|
+
// error. Aliased in the one shared normalizer, not per hook, so the
|
|
1955
|
+
// single-file and list shapes cannot diverge. `file` is still null while
|
|
1956
|
+
// loading — the top-level `url` remains the read to prefer.
|
|
1957
|
+
// (b) `hostApiUrlPatterns` gains `/api/files/` (matched anywhere, so the
|
|
1958
|
+
// origin-prefixed spelling is caught) plus quote-anchored `/api/` needles
|
|
1959
|
+
// for invented prefixes generally. The rule now blanks COMMENTS before
|
|
1960
|
+
// matching, so a comment quoting the bad path is not a finding, and a
|
|
1961
|
+
// legitimate third-party relative path can opt out with an
|
|
1962
|
+
// `appstudio-lint-ignore no-host-api-url` comment. Additive.
|
|
1963
|
+
version: "1.44.0",
|
|
1923
1964
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
1924
1965
|
hooks: HOOKS,
|
|
1925
1966
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -211,10 +211,14 @@ const HOOKS = [
|
|
|
211
211
|
name: "useFilestoreFiles",
|
|
212
212
|
signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
|
|
213
213
|
description:
|
|
214
|
-
"Browse the end-user's Filestore files in a project or
|
|
215
|
-
"The hook resolves owner_id from the host context (tenant for
|
|
216
|
-
"app user for personal) — the widget only chooses the
|
|
217
|
-
"
|
|
214
|
+
"Browse the end-user's Filestore files in a project, personal, or public " +
|
|
215
|
+
"space. The hook resolves owner_id from the host context (tenant for " +
|
|
216
|
+
"project/public, app user for personal) — the widget only chooses the " +
|
|
217
|
+
"space. A public space is world-readable: its files render even for " +
|
|
218
|
+
"logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
|
|
219
|
+
"{ data, meta } to the files array. Every row carries a ready-to-render " +
|
|
220
|
+
"`url` (its absolutized presigned_url, aliased by the filestore client) " +
|
|
221
|
+
"so a gallery renders `files.map(f => f.url)` directly.",
|
|
218
222
|
returnShape: {
|
|
219
223
|
files: "FilestoreFile[]",
|
|
220
224
|
loading: "boolean",
|
|
@@ -236,7 +240,12 @@ const HOOKS = [
|
|
|
236
240
|
"absolutized by the client so it loads on web AND native. An empty id " +
|
|
237
241
|
"collapses to { file: null, url: null } with no round-trip; a deleted / " +
|
|
238
242
|
"not-found id degrades the same way (url stays null, error carries the " +
|
|
239
|
-
"wire error) so a display widget shows its fallback instead of crashing."
|
|
243
|
+
"wire error) so a display widget shows its fallback instead of crashing. " +
|
|
244
|
+
"READ THE TOP-LEVEL `url`: `file` is null until the fetch resolves (and " +
|
|
245
|
+
"stays null for an empty id), so `file.url` throws on the first render. " +
|
|
246
|
+
"Every file record the client returns does carry the same `url` alias, " +
|
|
247
|
+
"so `file.url` is correct once you have null-checked `file`. " +
|
|
248
|
+
"NEVER build a file URL by hand from an id — no route serves one.",
|
|
240
249
|
returnShape: {
|
|
241
250
|
file: "FilestoreFile | null",
|
|
242
251
|
url: "string | null",
|
|
@@ -295,10 +304,10 @@ const HOOKS = [
|
|
|
295
304
|
name: "useFilestoreFolders",
|
|
296
305
|
signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
|
|
297
306
|
description:
|
|
298
|
-
"Browse the end-user's Filestore folders in a project or
|
|
299
|
-
"mirroring useFilestoreFiles for subfolder navigation. The hook
|
|
300
|
-
"owner_id from the host context; pass enabled:false to suspend
|
|
301
|
-
"Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
307
|
+
"Browse the end-user's Filestore folders in a project, personal, or public " +
|
|
308
|
+
"space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
|
|
309
|
+
"resolves owner_id from the host context; pass enabled:false to suspend " +
|
|
310
|
+
"fetching. Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
302
311
|
returnShape: {
|
|
303
312
|
folders: "FilestoreFolder[]",
|
|
304
313
|
loading: "boolean",
|
|
@@ -823,7 +832,7 @@ const PRIMITIVES = [
|
|
|
823
832
|
{
|
|
824
833
|
name: "DateTimePicker",
|
|
825
834
|
description:
|
|
826
|
-
'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native `<input type="date|time|datetime-local">` directly (react-native-web has no mapping for the RN datetimepicker library); native uses @react-native-community/datetimepicker.',
|
|
835
|
+
'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native `<input type="date|time|datetime-local">` directly (react-native-web has no mapping for the RN datetimepicker library); native uses @react-native-community/datetimepicker. The field themes itself from the workspace theme — text uses `colors.onSurface`, the border `colors.border`, and on web `color-scheme` follows the theme so the browser\'s built-in date UI stays legible on a dark surface; never style it with raw CSS or `document`.',
|
|
827
836
|
rnComponent: "@react-native-community/datetimepicker",
|
|
828
837
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
829
838
|
},
|
|
@@ -1090,7 +1099,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1090
1099
|
},
|
|
1091
1100
|
filestore: {
|
|
1092
1101
|
description:
|
|
1093
|
-
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal spaces) + BankID file signing. " +
|
|
1102
|
+
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal / public spaces) + BankID file signing. " +
|
|
1094
1103
|
"{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), remove(id), preview(id) }, " +
|
|
1095
1104
|
"folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
|
|
1096
1105
|
"signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
|
|
@@ -1209,12 +1218,11 @@ const BUNDLE_EXPORT_CONTRACT = [
|
|
|
1209
1218
|
// REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
|
|
1210
1219
|
// §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
|
|
1211
1220
|
// third-party APIs directly. Same-origin requests to the host's own
|
|
1212
|
-
// `/api/*` surface
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
// submission so authors learn the rule statically.
|
|
1221
|
+
// `/api/*` surface just fail: widget code never receives the JWT, so they
|
|
1222
|
+
// 401 (and an invented route like `/api/files/<id>` 404s). There is no
|
|
1223
|
+
// runtime gate — the soft linter warning (`no-host-api-url`) is the only
|
|
1224
|
+
// thing that catches it, which is why it flags relative `/api/` literals
|
|
1225
|
+
// too, not just the real `/api/v1` prefix.
|
|
1218
1226
|
const BANNED_APIS = [
|
|
1219
1227
|
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
1220
1228
|
{
|
|
@@ -1305,7 +1313,7 @@ const VETTED_IMPORTS = [
|
|
|
1305
1313
|
platforms: ["web", "native"],
|
|
1306
1314
|
category: "network",
|
|
1307
1315
|
description:
|
|
1308
|
-
"HTTP client for third-party APIs. Calls to the host's /api/* surface
|
|
1316
|
+
"HTTP client for third-party APIs. Calls to the host's own /api/* surface do not work — a widget is never given a JWT, so they 401 (and an invented path matches no route at all); use SDK hooks for workspace data.",
|
|
1309
1317
|
},
|
|
1310
1318
|
{
|
|
1311
1319
|
specifier: "date-fns",
|
|
@@ -1513,6 +1521,18 @@ const HOST_API_URL_PATTERNS = [
|
|
|
1513
1521
|
"/api/v1",
|
|
1514
1522
|
"/uploads/",
|
|
1515
1523
|
"Authorization: Bearer",
|
|
1524
|
+
// sc-3589 follow-up — the invented file route, matched ANYWHERE in a literal
|
|
1525
|
+
// so the origin-prefixed spelling (`${location.origin}/api/files/<id>`) is
|
|
1526
|
+
// caught as well as the bare relative one. No route serves this path.
|
|
1527
|
+
"/api/files/",
|
|
1528
|
+
// A QUOTED relative path into the host API — a broader net for invented
|
|
1529
|
+
// prefixes generally. Quote-anchored so an absolute third-party URL that
|
|
1530
|
+
// merely contains "/api/" (https://api.example.com/api/x) does not match.
|
|
1531
|
+
// Comments are blanked before matching, and a legitimate third-party
|
|
1532
|
+
// relative path can opt out with the `appstudio-lint-ignore` directive.
|
|
1533
|
+
'"/api/',
|
|
1534
|
+
"'/api/",
|
|
1535
|
+
"`/api/",
|
|
1516
1536
|
];
|
|
1517
1537
|
|
|
1518
1538
|
function deepFreeze(value) {
|
|
@@ -1919,7 +1939,28 @@ const CONTRACT = deepFreeze({
|
|
|
1919
1939
|
// default false): declares that the widget renders its own section header,
|
|
1920
1940
|
// so the app-builder places it as a whole section without stacking a
|
|
1921
1941
|
// heading above it. Additive — existing manifests omit it and read false.
|
|
1922
|
-
|
|
1942
|
+
// 1.43.0 (sc-3370) — the `<DateTimePicker>` primitive now themes itself from
|
|
1943
|
+
// the workspace theme on both hosts: text uses `colors.onSurface`, the
|
|
1944
|
+
// border `colors.border`, and the web input's `color-scheme` follows the
|
|
1945
|
+
// theme so the browser's built-in date UI stays legible on a dark surface.
|
|
1946
|
+
// Previously it hardcoded `color: inherit` / an unset scheme (web) and an
|
|
1947
|
+
// uncoloured trigger (native), rendering dark-on-dark. No prop changed —
|
|
1948
|
+
// behavioural fix, additive.
|
|
1949
|
+
// 1.44.0 (sc-3589 follow-up) — two FILE-display fixes. (a) The filestore
|
|
1950
|
+
// client's `withDisplayableUrl` normalizer now aliases the absolutized presigned_url as `url` on
|
|
1951
|
+
// EVERY file record, so `file.url` works for `useFilestoreFile` AND every
|
|
1952
|
+
// row of `useFilestoreFiles`. The wire field is `presigned_url`; reading
|
|
1953
|
+
// the wrong name returned undefined silently, rendering nothing with no
|
|
1954
|
+
// error. Aliased in the one shared normalizer, not per hook, so the
|
|
1955
|
+
// single-file and list shapes cannot diverge. `file` is still null while
|
|
1956
|
+
// loading — the top-level `url` remains the read to prefer.
|
|
1957
|
+
// (b) `hostApiUrlPatterns` gains `/api/files/` (matched anywhere, so the
|
|
1958
|
+
// origin-prefixed spelling is caught) plus quote-anchored `/api/` needles
|
|
1959
|
+
// for invented prefixes generally. The rule now blanks COMMENTS before
|
|
1960
|
+
// matching, so a comment quoting the bad path is not a finding, and a
|
|
1961
|
+
// legitimate third-party relative path can opt out with an
|
|
1962
|
+
// `appstudio-lint-ignore no-host-api-url` comment. Additive.
|
|
1963
|
+
version: "1.44.0",
|
|
1923
1964
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
1924
1965
|
hooks: HOOKS,
|
|
1925
1966
|
primitives: PRIMITIVES,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// REQ-WSDK-PLATFORM §6 — pure
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — pure, RN-free helpers for the `<DateTimePicker>`.
|
|
2
2
|
//
|
|
3
|
-
// Split out of
|
|
3
|
+
// Split out of the primitive so the formatting + theming contract can be
|
|
4
4
|
// unit-tested without importing react-native / the RN datetimepicker library
|
|
5
5
|
// (neither is installed in this package's node tree). No React, no RN here —
|
|
6
|
-
// just Date math and
|
|
6
|
+
// just Date math, string shaping, and a colour-luminance test.
|
|
7
7
|
|
|
8
8
|
export function parseToDate(value) {
|
|
9
9
|
if (value == null || value === "") return new Date();
|
|
@@ -51,3 +51,22 @@ export function formatDisplayLabel(value, mode) {
|
|
|
51
51
|
if (Number.isNaN(d.getTime())) return String(value);
|
|
52
52
|
return `${formatToIso(d, "date")} ${formatToIso(d, "time")}`;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// Perceived-luminance test for a hex colour (`#rgb` or `#rrggbb`). The web
|
|
56
|
+
// picker uses it to choose a matching `color-scheme` so the browser's built-in
|
|
57
|
+
// date UI (edit segments + calendar icon) stays legible on a dark themed
|
|
58
|
+
// surface. Anything unparseable is treated as light so a light theme is never
|
|
59
|
+
// darkened.
|
|
60
|
+
export function isDarkColor(hex) {
|
|
61
|
+
if (typeof hex !== "string") return false;
|
|
62
|
+
let h = hex.trim().replace(/^#/, "");
|
|
63
|
+
if (h.length === 3) {
|
|
64
|
+
h = h.split("").map((c) => c + c).join("");
|
|
65
|
+
}
|
|
66
|
+
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return false;
|
|
67
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
68
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
69
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
70
|
+
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
71
|
+
return luminance < 0.5;
|
|
72
|
+
}
|
package/dist/datetimepicker.js
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
// readers can locate the input by its label.
|
|
26
26
|
|
|
27
27
|
import React from "react";
|
|
28
|
+
import { useHostTheme } from "./hooks.js";
|
|
29
|
+
import { isDarkColor } from "./datetimepicker-format.js";
|
|
30
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
28
31
|
|
|
29
32
|
const MODE_TO_INPUT_TYPE = {
|
|
30
33
|
date: "date",
|
|
@@ -80,6 +83,7 @@ export function DateTimePicker({
|
|
|
80
83
|
disabled,
|
|
81
84
|
accessibilityLabel,
|
|
82
85
|
}) {
|
|
86
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
83
87
|
const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
|
|
84
88
|
const inputType = MODE_TO_INPUT_TYPE[effectiveMode];
|
|
85
89
|
const inputValue = _isoToInputValue(value, effectiveMode);
|
|
@@ -128,7 +132,13 @@ export function DateTimePicker({
|
|
|
128
132
|
// Inline styles match the SDK's other web-only primitives — the host's
|
|
129
133
|
// form widgets wrap this in their own labelled field, so the input just
|
|
130
134
|
// needs to look like a normal text input. `cursor: pointer` signals the
|
|
131
|
-
// whole field is the click affordance (sc-1878).
|
|
135
|
+
// whole field is the click affordance (sc-1878). Colours come from the
|
|
136
|
+
// theme so the field is legible on any surface (sc-3370); `colorScheme`
|
|
137
|
+
// steers the browser's built-in date UI (edit segments + calendar icon).
|
|
138
|
+
// The scheme keys off the theme's TEXT colour (onSurface, which the host
|
|
139
|
+
// already picked to contrast the surface) rather than re-deriving dark-vs-
|
|
140
|
+
// light from the surface with a second luminance formula that could disagree
|
|
141
|
+
// with the host: light text ⇒ dark surface ⇒ dark scheme.
|
|
132
142
|
const style = {
|
|
133
143
|
boxSizing: "border-box",
|
|
134
144
|
width: "100%",
|
|
@@ -136,11 +146,12 @@ export function DateTimePicker({
|
|
|
136
146
|
padding: "8px 12px",
|
|
137
147
|
fontSize: 16,
|
|
138
148
|
fontFamily: "inherit",
|
|
139
|
-
color:
|
|
149
|
+
color: theme.colors.onSurface,
|
|
140
150
|
backgroundColor: "transparent",
|
|
151
|
+
colorScheme: isDarkColor(theme.colors.onSurface) ? "light" : "dark",
|
|
141
152
|
borderWidth: 1,
|
|
142
153
|
borderStyle: "solid",
|
|
143
|
-
borderColor:
|
|
154
|
+
borderColor: theme.colors.border,
|
|
144
155
|
borderRadius: 6,
|
|
145
156
|
outline: "none",
|
|
146
157
|
cursor: disabled ? "default" : "pointer",
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
formatToIso,
|
|
33
33
|
formatDisplayLabel,
|
|
34
34
|
} from "./datetimepicker-format.js";
|
|
35
|
+
import { useHostTheme } from "./hooks.js";
|
|
36
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
35
37
|
|
|
36
38
|
const styles = StyleSheet.create({
|
|
37
39
|
trigger: {
|
|
@@ -39,7 +41,6 @@ const styles = StyleSheet.create({
|
|
|
39
41
|
paddingVertical: 8,
|
|
40
42
|
paddingHorizontal: 12,
|
|
41
43
|
borderWidth: 1,
|
|
42
|
-
borderColor: "rgba(0, 0, 0, 0.16)",
|
|
43
44
|
borderRadius: 6,
|
|
44
45
|
justifyContent: "center",
|
|
45
46
|
},
|
|
@@ -57,6 +58,7 @@ export function DateTimePicker({
|
|
|
57
58
|
disabled,
|
|
58
59
|
accessibilityLabel,
|
|
59
60
|
}) {
|
|
61
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
60
62
|
const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
|
|
61
63
|
const [open, setOpen] = useState(false);
|
|
62
64
|
const dateValue = useMemo(() => parseToDate(value), [value]);
|
|
@@ -89,11 +91,21 @@ export function DateTimePicker({
|
|
|
89
91
|
accessibilityRole: "button",
|
|
90
92
|
accessibilityLabel,
|
|
91
93
|
accessibilityState: { disabled: !!disabled },
|
|
92
|
-
|
|
94
|
+
// Themed border + text so the trigger is legible on any surface (sc-3370).
|
|
95
|
+
style: [
|
|
96
|
+
styles.trigger,
|
|
97
|
+
{ borderColor: theme.colors.border },
|
|
98
|
+
disabled && styles.triggerDisabled,
|
|
99
|
+
],
|
|
93
100
|
},
|
|
94
101
|
React.createElement(
|
|
95
102
|
Text,
|
|
96
|
-
{
|
|
103
|
+
{
|
|
104
|
+
style: [
|
|
105
|
+
isEmpty ? styles.placeholder : styles.label,
|
|
106
|
+
{ color: theme.colors.onSurface },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
97
109
|
formatDisplayLabel(value, effectiveMode),
|
|
98
110
|
),
|
|
99
111
|
);
|
package/dist/hooks.js
CHANGED
|
@@ -91,6 +91,17 @@ export function useTheme() {
|
|
|
91
91
|
return ctx.workspace.theme;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// @internal — reads the host theme WITHOUT throwing when no provider is
|
|
95
|
+
// mounted (returns undefined instead), unlike useTheme() which asserts a
|
|
96
|
+
// widget context. Low-level primitives (DateTimePicker) read it so they stay
|
|
97
|
+
// renderable anywhere, applying their own default tokens on the undefined
|
|
98
|
+
// case. Not part of the public widget hook surface — not re-exported from
|
|
99
|
+
// index.js.
|
|
100
|
+
export function useHostTheme() {
|
|
101
|
+
const ctx = useContext(HostWidgetContext);
|
|
102
|
+
return ctx && ctx.workspace ? ctx.workspace.theme : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
/**
|
|
95
106
|
* REQ-THEME-13 — returns the author-set per-widget style values: the object the
|
|
96
107
|
* host delivers under `props.style`, keyed by the style-field names the widget
|
|
@@ -1438,8 +1449,10 @@ export function useAssetsByTag(tag, options) {
|
|
|
1438
1449
|
* ==========================================================================*/
|
|
1439
1450
|
|
|
1440
1451
|
// Resolve the owner_id a filestore query needs from the host context: a PROJECT
|
|
1441
|
-
// space is owned by the tenant (ctx.workspace.id); a PERSONAL space is
|
|
1442
|
-
// the signed-in app user (ctx.user.id). The widget only chooses the
|
|
1452
|
+
// or PUBLIC space is owned by the tenant (ctx.workspace.id); a PERSONAL space is
|
|
1453
|
+
// owned by the signed-in app user (ctx.user.id). The widget only chooses the
|
|
1454
|
+
// space. PUBLIC is world-readable, so its files resolve even when there is no
|
|
1455
|
+
// signed-in user (ctx.workspace.id is set for a logged-out Player visitor too).
|
|
1443
1456
|
function _filestoreOwnerId(ctx, spaceType) {
|
|
1444
1457
|
const space = String(spaceType || "project").toUpperCase();
|
|
1445
1458
|
if (space === "PERSONAL") return (ctx.user && ctx.user.id) || null;
|
|
@@ -1535,7 +1548,8 @@ export function useFilestoreFiles(options) {
|
|
|
1535
1548
|
* An empty / null id collapses to `{ file: null, url: null }` with no network
|
|
1536
1549
|
* round-trip; a deleted or not-found id degrades the same way (`url` stays
|
|
1537
1550
|
* null and `error` carries the wire error) so a display widget shows its
|
|
1538
|
-
* fallback instead of crashing.
|
|
1551
|
+
* fallback instead of crashing. Read the top-level `url` — `file` is null
|
|
1552
|
+
* until the fetch resolves, so `file.url` throws on the first render.
|
|
1539
1553
|
*/
|
|
1540
1554
|
export function useFilestoreFile(fileId) {
|
|
1541
1555
|
const ctx = useWidgetContextOrThrow("useFilestoreFile");
|
package/dist/linter.cjs
CHANGED
|
@@ -69,7 +69,7 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
69
69
|
// left intact: real code lives there and must still be scanned (`${window}`
|
|
70
70
|
// is a genuine escape). Backslash escapes inside strings/templates are
|
|
71
71
|
// consumed so an escaped quote (`"\""`) doesn't end the literal early.
|
|
72
|
-
function _stripNonCode(source) {
|
|
72
|
+
function _stripNonCode(source, { keepStrings = false } = {}) {
|
|
73
73
|
let out = "";
|
|
74
74
|
const n = source.length;
|
|
75
75
|
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
@@ -84,6 +84,10 @@ function _stripNonCode(source) {
|
|
|
84
84
|
const blank = (ch) => {
|
|
85
85
|
out += ch === "\n" || ch === "\r" ? ch : " ";
|
|
86
86
|
};
|
|
87
|
+
// String / template CONTENT: blanked for the banned-identifier scan
|
|
88
|
+
// (prose must not trip `no-window`), kept for the host-API-URL scan,
|
|
89
|
+
// whose whole job is to find a URL literal.
|
|
90
|
+
const str = keepStrings ? keep : blank;
|
|
87
91
|
let i = 0;
|
|
88
92
|
while (i < n) {
|
|
89
93
|
const ch = source[i];
|
|
@@ -101,15 +105,15 @@ function _stripNonCode(source) {
|
|
|
101
105
|
i += 2;
|
|
102
106
|
} else if (ch === "'") {
|
|
103
107
|
mode = "sq";
|
|
104
|
-
|
|
108
|
+
str(ch);
|
|
105
109
|
i += 1;
|
|
106
110
|
} else if (ch === '"') {
|
|
107
111
|
mode = "dq";
|
|
108
|
-
|
|
112
|
+
str(ch);
|
|
109
113
|
i += 1;
|
|
110
114
|
} else if (ch === "`") {
|
|
111
115
|
mode = "tmpl";
|
|
112
|
-
|
|
116
|
+
str(ch);
|
|
113
117
|
i += 1;
|
|
114
118
|
} else if (ch === "{") {
|
|
115
119
|
braceDepth += 1;
|
|
@@ -123,7 +127,7 @@ function _stripNonCode(source) {
|
|
|
123
127
|
) {
|
|
124
128
|
tmplStack.pop();
|
|
125
129
|
mode = "tmpl";
|
|
126
|
-
|
|
130
|
+
str(ch);
|
|
127
131
|
} else {
|
|
128
132
|
keep(ch);
|
|
129
133
|
}
|
|
@@ -153,12 +157,12 @@ function _stripNonCode(source) {
|
|
|
153
157
|
} else if (mode === "sq" || mode === "dq") {
|
|
154
158
|
const quote = mode === "sq" ? "'" : '"';
|
|
155
159
|
if (ch === "\\") {
|
|
156
|
-
|
|
157
|
-
if (i + 1 < n)
|
|
160
|
+
str(ch);
|
|
161
|
+
if (i + 1 < n) str(nx);
|
|
158
162
|
i += 2;
|
|
159
163
|
} else if (ch === quote) {
|
|
160
164
|
mode = "code";
|
|
161
|
-
|
|
165
|
+
str(ch);
|
|
162
166
|
i += 1;
|
|
163
167
|
} else if (ch === "\n") {
|
|
164
168
|
// A bare newline terminates an unterminated string in JS; bail back
|
|
@@ -167,18 +171,18 @@ function _stripNonCode(source) {
|
|
|
167
171
|
keep(ch);
|
|
168
172
|
i += 1;
|
|
169
173
|
} else {
|
|
170
|
-
|
|
174
|
+
str(ch);
|
|
171
175
|
i += 1;
|
|
172
176
|
}
|
|
173
177
|
} else {
|
|
174
178
|
// mode === "tmpl"
|
|
175
179
|
if (ch === "\\") {
|
|
176
|
-
|
|
177
|
-
if (i + 1 < n)
|
|
180
|
+
str(ch);
|
|
181
|
+
if (i + 1 < n) str(nx);
|
|
178
182
|
i += 2;
|
|
179
183
|
} else if (ch === "`") {
|
|
180
184
|
mode = "code";
|
|
181
|
-
|
|
185
|
+
str(ch);
|
|
182
186
|
i += 1;
|
|
183
187
|
} else if (ch === "$" && nx === "{") {
|
|
184
188
|
// Enter an expression hole. Remember the brace depth the template
|
|
@@ -190,7 +194,7 @@ function _stripNonCode(source) {
|
|
|
190
194
|
keep(nx);
|
|
191
195
|
i += 2;
|
|
192
196
|
} else {
|
|
193
|
-
|
|
197
|
+
str(ch);
|
|
194
198
|
i += 1;
|
|
195
199
|
}
|
|
196
200
|
}
|
|
@@ -332,11 +336,37 @@ function _importRules(source, manifest) {
|
|
|
332
336
|
return findings;
|
|
333
337
|
}
|
|
334
338
|
|
|
339
|
+
// Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
|
|
340
|
+
// relative path against an `axios` baseURL cannot avoid the substring match,
|
|
341
|
+
// and post-sc-3589 an unavoidable finding burns AI repair turns on correct
|
|
342
|
+
// code. The directive may sit on the offending line or the line above it.
|
|
343
|
+
const HOST_API_URL_IGNORE = "appstudio-lint-ignore no-host-api-url";
|
|
344
|
+
|
|
345
|
+
function _hostApiUrlIgnoredLines(source) {
|
|
346
|
+
const ignored = new Set();
|
|
347
|
+
const lines = source.split(/\r?\n/);
|
|
348
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
349
|
+
const line = lines[i];
|
|
350
|
+
if (!line.includes(HOST_API_URL_IGNORE)) continue;
|
|
351
|
+
ignored.add(i + 1);
|
|
352
|
+
// A directive on its OWN line also exempts the line below it. A TRAILING
|
|
353
|
+
// comment exempts only its own line — otherwise it would silently cover
|
|
354
|
+
// the next statement, which may be a genuine hand-built host URL.
|
|
355
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) ignored.add(i + 2);
|
|
356
|
+
}
|
|
357
|
+
return ignored;
|
|
358
|
+
}
|
|
359
|
+
|
|
335
360
|
function _hostApiUrlRules(source) {
|
|
336
361
|
const findings = [];
|
|
337
362
|
const patterns = CONTRACT.hostApiUrlPatterns || [];
|
|
338
|
-
const
|
|
363
|
+
const ignored = _hostApiUrlIgnoredLines(source);
|
|
364
|
+
// Comments are blanked first: a comment or JSDoc line quoting
|
|
365
|
+
// `/api/files/<id>` — exactly the text the docs tell authors NOT to write —
|
|
366
|
+
// must not become a blocking publish check.
|
|
367
|
+
const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
339
368
|
for (let i = 0; i < lines.length; i += 1) {
|
|
369
|
+
if (ignored.has(i + 1)) continue;
|
|
340
370
|
const line = lines[i];
|
|
341
371
|
for (const needle of patterns) {
|
|
342
372
|
if (line.includes(needle)) {
|
|
@@ -344,9 +374,14 @@ function _hostApiUrlRules(source) {
|
|
|
344
374
|
rule: "no-host-api-url",
|
|
345
375
|
severity: "warning",
|
|
346
376
|
label:
|
|
347
|
-
`source contains "${needle}" —
|
|
348
|
-
`
|
|
349
|
-
`
|
|
377
|
+
`source contains "${needle}" — a widget cannot reach the ` +
|
|
378
|
+
`AppStudio host API: it is never given a JWT, so the call 401s, ` +
|
|
379
|
+
`and an invented path like /api/files/<id> matches no route at ` +
|
|
380
|
+
`all. Read workspace data through SDK hooks (a stored file via ` +
|
|
381
|
+
`useFilestoreFile(id).url — never a URL you build yourself). ` +
|
|
382
|
+
`\`axios\`/\`fetch\` remain fine for THIRD-PARTY APIs; if one ` +
|
|
383
|
+
`genuinely needs a relative /api path, add the comment ` +
|
|
384
|
+
`"${HOST_API_URL_IGNORE}" on or above the line.`,
|
|
350
385
|
line: i + 1,
|
|
351
386
|
snippet: line.trim().slice(0, 200),
|
|
352
387
|
});
|
package/dist/linter.js
CHANGED
|
@@ -70,7 +70,7 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
70
70
|
// left intact: real code lives there and must still be scanned (`${window}`
|
|
71
71
|
// is a genuine escape). Backslash escapes inside strings/templates are
|
|
72
72
|
// consumed so an escaped quote (`"\""`) doesn't end the literal early.
|
|
73
|
-
function _stripNonCode(source) {
|
|
73
|
+
function _stripNonCode(source, { keepStrings = false } = {}) {
|
|
74
74
|
let out = "";
|
|
75
75
|
const n = source.length;
|
|
76
76
|
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
@@ -85,6 +85,10 @@ function _stripNonCode(source) {
|
|
|
85
85
|
const blank = (ch) => {
|
|
86
86
|
out += ch === "\n" || ch === "\r" ? ch : " ";
|
|
87
87
|
};
|
|
88
|
+
// String / template CONTENT: blanked for the banned-identifier scan
|
|
89
|
+
// (prose must not trip `no-window`), kept for the host-API-URL scan,
|
|
90
|
+
// whose whole job is to find a URL literal.
|
|
91
|
+
const str = keepStrings ? keep : blank;
|
|
88
92
|
let i = 0;
|
|
89
93
|
while (i < n) {
|
|
90
94
|
const ch = source[i];
|
|
@@ -102,15 +106,15 @@ function _stripNonCode(source) {
|
|
|
102
106
|
i += 2;
|
|
103
107
|
} else if (ch === "'") {
|
|
104
108
|
mode = "sq";
|
|
105
|
-
|
|
109
|
+
str(ch);
|
|
106
110
|
i += 1;
|
|
107
111
|
} else if (ch === '"') {
|
|
108
112
|
mode = "dq";
|
|
109
|
-
|
|
113
|
+
str(ch);
|
|
110
114
|
i += 1;
|
|
111
115
|
} else if (ch === "`") {
|
|
112
116
|
mode = "tmpl";
|
|
113
|
-
|
|
117
|
+
str(ch);
|
|
114
118
|
i += 1;
|
|
115
119
|
} else if (ch === "{") {
|
|
116
120
|
braceDepth += 1;
|
|
@@ -124,7 +128,7 @@ function _stripNonCode(source) {
|
|
|
124
128
|
) {
|
|
125
129
|
tmplStack.pop();
|
|
126
130
|
mode = "tmpl";
|
|
127
|
-
|
|
131
|
+
str(ch);
|
|
128
132
|
} else {
|
|
129
133
|
keep(ch);
|
|
130
134
|
}
|
|
@@ -154,12 +158,12 @@ function _stripNonCode(source) {
|
|
|
154
158
|
} else if (mode === "sq" || mode === "dq") {
|
|
155
159
|
const quote = mode === "sq" ? "'" : '"';
|
|
156
160
|
if (ch === "\\") {
|
|
157
|
-
|
|
158
|
-
if (i + 1 < n)
|
|
161
|
+
str(ch);
|
|
162
|
+
if (i + 1 < n) str(nx);
|
|
159
163
|
i += 2;
|
|
160
164
|
} else if (ch === quote) {
|
|
161
165
|
mode = "code";
|
|
162
|
-
|
|
166
|
+
str(ch);
|
|
163
167
|
i += 1;
|
|
164
168
|
} else if (ch === "\n") {
|
|
165
169
|
// A bare newline terminates an unterminated string in JS; bail back
|
|
@@ -168,18 +172,18 @@ function _stripNonCode(source) {
|
|
|
168
172
|
keep(ch);
|
|
169
173
|
i += 1;
|
|
170
174
|
} else {
|
|
171
|
-
|
|
175
|
+
str(ch);
|
|
172
176
|
i += 1;
|
|
173
177
|
}
|
|
174
178
|
} else {
|
|
175
179
|
// mode === "tmpl"
|
|
176
180
|
if (ch === "\\") {
|
|
177
|
-
|
|
178
|
-
if (i + 1 < n)
|
|
181
|
+
str(ch);
|
|
182
|
+
if (i + 1 < n) str(nx);
|
|
179
183
|
i += 2;
|
|
180
184
|
} else if (ch === "`") {
|
|
181
185
|
mode = "code";
|
|
182
|
-
|
|
186
|
+
str(ch);
|
|
183
187
|
i += 1;
|
|
184
188
|
} else if (ch === "$" && nx === "{") {
|
|
185
189
|
// Enter an expression hole. Remember the brace depth the template
|
|
@@ -191,7 +195,7 @@ function _stripNonCode(source) {
|
|
|
191
195
|
keep(nx);
|
|
192
196
|
i += 2;
|
|
193
197
|
} else {
|
|
194
|
-
|
|
198
|
+
str(ch);
|
|
195
199
|
i += 1;
|
|
196
200
|
}
|
|
197
201
|
}
|
|
@@ -204,10 +208,10 @@ function _stripNonCode(source) {
|
|
|
204
208
|
//
|
|
205
209
|
// REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
|
|
206
210
|
// import list now (`CONTRACT.vettedImports`) — widgets may call third-party
|
|
207
|
-
// APIs directly. Calls to the host's own /api/* surface are
|
|
208
|
-
// runtime
|
|
209
|
-
// `no-host-api-url` rule below
|
|
210
|
-
//
|
|
211
|
+
// APIs directly. Calls to the host's own /api/* surface are not gated at
|
|
212
|
+
// runtime — they simply 401 (widget code never gets the JWT), so the soft
|
|
213
|
+
// `no-host-api-url` rule below is the only thing that catches them, and it
|
|
214
|
+
// flags relative `/api/` literals as well as the real `/api/v1` prefix.
|
|
211
215
|
const EXTRA_RULES = [
|
|
212
216
|
{
|
|
213
217
|
id: "no-auth-store-import",
|
|
@@ -369,14 +373,43 @@ function _importRules(source, manifest) {
|
|
|
369
373
|
}
|
|
370
374
|
|
|
371
375
|
// REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
|
|
372
|
-
// substrings.
|
|
373
|
-
//
|
|
374
|
-
//
|
|
376
|
+
// substrings. `severity: "warning"` so a HUMAN-authored submission still
|
|
377
|
+
// publishes and the marketplace review queue flags it for a human pass.
|
|
378
|
+
// sc-3589 — an AI-agent widget has no such review queue, so its publish loop
|
|
379
|
+
// treats every finding (this one included) as a blocking check that drives a
|
|
380
|
+
// repair turn; a lone unrepaired one still publishes with a warning.
|
|
381
|
+
// False positives are possible: the needles are plain substring matches.
|
|
382
|
+
// Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
|
|
383
|
+
// relative path against an `axios` baseURL cannot avoid the substring match,
|
|
384
|
+
// and post-sc-3589 an unavoidable finding burns AI repair turns on correct
|
|
385
|
+
// code. The directive may sit on the offending line or the line above it.
|
|
386
|
+
const HOST_API_URL_IGNORE = "appstudio-lint-ignore no-host-api-url";
|
|
387
|
+
|
|
388
|
+
function _hostApiUrlIgnoredLines(source) {
|
|
389
|
+
const ignored = new Set();
|
|
390
|
+
const lines = source.split(/\r?\n/);
|
|
391
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
392
|
+
const line = lines[i];
|
|
393
|
+
if (!line.includes(HOST_API_URL_IGNORE)) continue;
|
|
394
|
+
ignored.add(i + 1);
|
|
395
|
+
// A directive on its OWN line also exempts the line below it. A TRAILING
|
|
396
|
+
// comment exempts only its own line — otherwise it would silently cover
|
|
397
|
+
// the next statement, which may be a genuine hand-built host URL.
|
|
398
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) ignored.add(i + 2);
|
|
399
|
+
}
|
|
400
|
+
return ignored;
|
|
401
|
+
}
|
|
402
|
+
|
|
375
403
|
function _hostApiUrlRules(source) {
|
|
376
404
|
const findings = [];
|
|
377
405
|
const patterns = CONTRACT.hostApiUrlPatterns || [];
|
|
378
|
-
const
|
|
406
|
+
const ignored = _hostApiUrlIgnoredLines(source);
|
|
407
|
+
// Comments are blanked first: a comment or JSDoc line quoting
|
|
408
|
+
// `/api/files/<id>` — exactly the text the docs tell authors NOT to write —
|
|
409
|
+
// must not become a blocking publish check.
|
|
410
|
+
const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
379
411
|
for (let i = 0; i < lines.length; i += 1) {
|
|
412
|
+
if (ignored.has(i + 1)) continue;
|
|
380
413
|
const line = lines[i];
|
|
381
414
|
for (const needle of patterns) {
|
|
382
415
|
if (line.includes(needle)) {
|
|
@@ -384,9 +417,14 @@ function _hostApiUrlRules(source) {
|
|
|
384
417
|
rule: "no-host-api-url",
|
|
385
418
|
severity: "warning",
|
|
386
419
|
label:
|
|
387
|
-
`source contains "${needle}" —
|
|
388
|
-
`
|
|
389
|
-
`
|
|
420
|
+
`source contains "${needle}" — a widget cannot reach the ` +
|
|
421
|
+
`AppStudio host API: it is never given a JWT, so the call 401s, ` +
|
|
422
|
+
`and an invented path like /api/files/<id> matches no route at ` +
|
|
423
|
+
`all. Read workspace data through SDK hooks (a stored file via ` +
|
|
424
|
+
`useFilestoreFile(id).url — never a URL you build yourself). ` +
|
|
425
|
+
`\`axios\`/\`fetch\` remain fine for THIRD-PARTY APIs; if one ` +
|
|
426
|
+
`genuinely needs a relative /api path, add the comment ` +
|
|
427
|
+
`"${HOST_API_URL_IGNORE}" on or above the line.`,
|
|
390
428
|
line: i + 1,
|
|
391
429
|
snippet: line.trim().slice(0, 200),
|
|
392
430
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|