@colixsystems/widget-sdk 0.82.0 → 0.84.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 +14 -2
- package/dist/contract.cjs +2 -2
- package/dist/contract.js +2 -2
- package/dist/hooks.js +58 -14
- package/dist/index.d.ts +47 -1
- package/dist/linter.cjs +76 -0
- package/dist/linter.js +92 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
48
48
|
| **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
49
49
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
50
50
|
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
|
|
51
|
-
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
51
|
+
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
|
|
52
52
|
| **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
|
|
53
53
|
| **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider?, purpose?, pollIntervalMs? })` | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
|
|
54
54
|
|
|
@@ -62,6 +62,18 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
62
62
|
|
|
63
63
|
`v0.77.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**.
|
|
64
64
|
|
|
65
|
+
### What's new in 0.84.0 (contract 1.59.0)
|
|
66
|
+
|
|
67
|
+
**A charge is denominated in the WORKSPACE's currency, and a widget that hardcodes a different one no longer publishes (sc-4649).** Every workspace picks the currency it charges its app users in, and `POST /payments/widget-charge` refuses any other code with `UNSUPPORTED_CURRENCY` — but nothing told a widget author which one that was. A widget priced in EUR for a workspace selling in SEK compiled, rendered, and looked finished, then failed every single checkout; the buyer read that as a generic "payment failed" and retried forever. Two changes: `currency` on `requestPayment` is best **omitted** (the platform applies the workspace's own, so it can never be wrong), and a literal that disagrees is now a publish-blocking `payment-currency` finding. Because the expected code is **per-workspace**, the SDK cannot know it: the rule fires only when the caller supplies `lintSource(source, { paymentCurrency })`, which the platform's publish gate does and a local `appstudio-widget lint` does not — it stays silent rather than guessing and flagging correct code. The rule is scoped to the argument of a `requestPayment(...)` call, so a `currency` field elsewhere (a datastore column, an `Intl.NumberFormat` option) is untouched. `CONTRACT.version` → `1.59.0`. Additive; a widget that omits `currency` or already matches its workspace is unaffected.
|
|
68
|
+
|
|
69
|
+
### What's new in 0.83.0 (contract 1.58.0)
|
|
70
|
+
|
|
71
|
+
**`PaymentError` tells you WHY a charge was refused, and whether retrying could ever help (sc-4650).** `usePayments()` mapped its rejections by reading `err.response`, but `@colixsystems/payments-client` throws typed errors carrying `.code` / `.status` / `.details` (the parsed error envelope) and no `.response` at all — so every server refusal arrived as `code: "INTERNAL"` and the real reason was buried on `err.cause`. A widget could not tell a workspace that has not declared its business identity yet (`BUSINESS_IDENTITY_REQUIRED`, which no retry clears) from a declined card.
|
|
72
|
+
|
|
73
|
+
- **Both error shapes are read, and the envelope wins.** The mapper takes the server's own `code` (`BUSINESS_IDENTITY_REQUIRED`, `UNSUPPORTED_CURRENCY`, `PAYMENTS_SCOPE_NOT_GRANTED`, …) over the client's status-derived class code, and keeps the server's user-safe `message`. The host's local `.response` rejections (no install bound → `PAYMENTS_UNAVAILABLE`) still map exactly as before.
|
|
74
|
+
- **New `retryable` flag.** `false` for any refusal only the workspace owner, the manifest, or the amount can lift; `true` for a decline, a provider blip, or an unknown failure. Branch on it: render `err.message` and drop the retry control when it is `false`, and keep "try again" for the retryable case only.
|
|
75
|
+
- **New lint warning `payment-error-not-branched`.** A widget that calls `requestPayment()` but never reads `retryable` (or branches on an explicit `err.code`) is flagged — non-blocking, so it never fails a publish.
|
|
76
|
+
|
|
65
77
|
### What's new in 0.82.0 (contract 1.57.0)
|
|
66
78
|
|
|
67
79
|
**Server-action scripts can notify a record's permission subjects (REQ-ACTION-NOTIFY-SUBJECTS, sc-4586).** `await notifications.notifyRecordSubjects(tableId, recordId, { title, body, link, emit_email, emit_push, exclude_user_id })` resolves to `{ recipients }` and notifies every app user whose per-record grant lets them **read** that record. This addresses the one audience the other two primitives cannot name: when membership *is* the ACL, there is no recipient column to read. The Chat widget is the case in point — a channel's participants ARE that channel record's grants, so neither a `recipient_expr` (which resolves only a fixed id or a single user/group reference column) nor a `notifyUser` loop over a column that does not exist can reach them. Subject kinds follow REQ-ACL-09: a `user` grant notifies its user, a `group` grant expands through its memberships, and the two synthetic kinds (`authenticated`, `everyone`) are skipped because they address the whole workspace rather than a membership. Recipients are **deduplicated**, so someone reachable through both a direct grant and a granted group is notified once, and `exclude_user_id` drops one — pass the author so nobody is notified of their own write. Dispatch goes through the same `notifyUser` path as before, so the always-written inbox row, the preference-gated email + push mirrors, the link sanitiser and the title/body caps are inherited unchanged, and written rows count toward the same per-run cap of 500. The tenant is bound host-side and the table/record pair is verified against it, so a foreign or missing id resolves to `{ recipients: 0 }` rather than distinguishing "absent" from "not yours". **The script never receives the member list — only the count.** `CONTRACT.version` → `1.57.0`. Additive; no widget hook, primitive, manifest field, or token changed shape.
|
|
@@ -628,7 +640,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
628
640
|
|
|
629
641
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
630
642
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
631
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError
|
|
643
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
|
|
632
644
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
633
645
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
634
646
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
package/dist/contract.cjs
CHANGED
|
@@ -803,7 +803,7 @@ const HOOKS = [
|
|
|
803
803
|
signature: "usePayments()",
|
|
804
804
|
returnShape: {
|
|
805
805
|
requestPayment:
|
|
806
|
-
"({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError",
|
|
806
|
+
"({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
|
|
807
807
|
getPayment:
|
|
808
808
|
"(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
|
|
809
809
|
},
|
|
@@ -2524,7 +2524,7 @@ const CONTRACT = deepFreeze({
|
|
|
2524
2524
|
// `recipient_expr` shape and no recipient column can name them. The member
|
|
2525
2525
|
// list stays host-side — a script receives a count, never the ids — and
|
|
2526
2526
|
// `exclude_user_id` keeps an author off their own message.
|
|
2527
|
-
version: "1.
|
|
2527
|
+
version: "1.59.0",
|
|
2528
2528
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2529
2529
|
hooks: HOOKS,
|
|
2530
2530
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -803,7 +803,7 @@ const HOOKS = [
|
|
|
803
803
|
signature: "usePayments()",
|
|
804
804
|
returnShape: {
|
|
805
805
|
requestPayment:
|
|
806
|
-
"({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError",
|
|
806
|
+
"({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
|
|
807
807
|
getPayment:
|
|
808
808
|
"(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
|
|
809
809
|
},
|
|
@@ -2524,7 +2524,7 @@ const CONTRACT = deepFreeze({
|
|
|
2524
2524
|
// `recipient_expr` shape and no recipient column can name them. The member
|
|
2525
2525
|
// list stays host-side — a script receives a count, never the ids — and
|
|
2526
2526
|
// `exclude_user_id` keeps an author off their own message.
|
|
2527
|
-
version: "1.
|
|
2527
|
+
version: "1.59.0",
|
|
2528
2528
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2529
2529
|
hooks: HOOKS,
|
|
2530
2530
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -3462,39 +3462,83 @@ export function useIdentification(options) {
|
|
|
3462
3462
|
* requestPayment, getPayment. Covers: usePayments.
|
|
3463
3463
|
* ==========================================================================*/
|
|
3464
3464
|
|
|
3465
|
+
// sc-4650 — the refusals that retrying the SAME charge can never clear: the
|
|
3466
|
+
// workspace, the widget manifest, or the amount has to change first. Everything
|
|
3467
|
+
// else (a decline, a provider blip, an unknown failure) is worth another
|
|
3468
|
+
// attempt. A widget reads this through `PaymentError.retryable`.
|
|
3469
|
+
const NON_RETRYABLE_PAYMENT_CODES = new Set([
|
|
3470
|
+
"AUTH_REQUIRED",
|
|
3471
|
+
"AMOUNT_TOO_LARGE",
|
|
3472
|
+
"BUSINESS_IDENTITY_REQUIRED",
|
|
3473
|
+
"CONNECT_NOT_READY",
|
|
3474
|
+
"FORBIDDEN",
|
|
3475
|
+
"INVALID_AMOUNT",
|
|
3476
|
+
"NOT_FOUND",
|
|
3477
|
+
"PAYMENTS_DISABLED",
|
|
3478
|
+
"PAYMENTS_SCOPE_NOT_GRANTED",
|
|
3479
|
+
"PAYMENTS_UNAVAILABLE",
|
|
3480
|
+
"UNSUPPORTED_CURRENCY",
|
|
3481
|
+
"VALIDATION",
|
|
3482
|
+
]);
|
|
3483
|
+
|
|
3465
3484
|
/**
|
|
3466
3485
|
* Structured error thrown by `usePayments` callbacks. Carries a stable
|
|
3467
|
-
* `code` so widgets can branch without parsing message strings
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
3471
|
-
*
|
|
3486
|
+
* `code` so widgets can branch without parsing message strings, and
|
|
3487
|
+
* `retryable` so they can tell "try again" from "this cannot work yet".
|
|
3488
|
+
*
|
|
3489
|
+
* `code` is the server's own code when it sent one, else a status-derived
|
|
3490
|
+
* fallback:
|
|
3491
|
+
* - "AUTH_REQUIRED" — no signed-in app user
|
|
3492
|
+
* - "BUSINESS_IDENTITY_REQUIRED" — the workspace has not declared its
|
|
3493
|
+
* business identity, so it may not collect
|
|
3494
|
+
* money yet
|
|
3495
|
+
* - "PAYMENTS_SCOPE_NOT_GRANTED" — widget lacks payments.charge:appUser
|
|
3496
|
+
* - "PAYMENTS_UNAVAILABLE" — no installed widget / no payments host
|
|
3497
|
+
* - "UNSUPPORTED_CURRENCY" — not the platform's charge currency
|
|
3472
3498
|
* - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" — bad request
|
|
3473
3499
|
* - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
|
|
3474
|
-
* - "DECLINED"
|
|
3475
|
-
* - "INTERNAL"
|
|
3500
|
+
* - "DECLINED" — the charge was declined
|
|
3501
|
+
* - "INTERNAL" — anything else
|
|
3502
|
+
*
|
|
3503
|
+
* `message` is the server's own user-safe sentence when it sent one — show it
|
|
3504
|
+
* for a non-retryable refusal rather than a generic "payment failed", which
|
|
3505
|
+
* tells the payer nothing about a block only the workspace owner can lift.
|
|
3476
3506
|
*/
|
|
3477
3507
|
export class PaymentError extends Error {
|
|
3478
3508
|
constructor(code, message, opts) {
|
|
3479
3509
|
super(message);
|
|
3480
3510
|
this.name = "PaymentError";
|
|
3481
3511
|
this.code = code;
|
|
3512
|
+
this.retryable = !NON_RETRYABLE_PAYMENT_CODES.has(code);
|
|
3482
3513
|
if (opts && opts.cause) this.cause = opts.cause;
|
|
3483
3514
|
}
|
|
3484
3515
|
}
|
|
3485
3516
|
|
|
3517
|
+
// sc-4650 — the payments client throws typed `PaymentsError` subclasses that
|
|
3518
|
+
// carry `.code` / `.status` / `.details` (the parsed error envelope) and NO
|
|
3519
|
+
// `.response`, while the host's local rejections carry `.response` instead.
|
|
3520
|
+
// Reading only `.response` collapsed every server refusal to "INTERNAL" and
|
|
3521
|
+
// buried the real code, so a widget could not tell a business-identity block
|
|
3522
|
+
// from a declined card. Read BOTH shapes, and prefer the envelope's `code`
|
|
3523
|
+
// (the precise reason) over the client's status-derived class code.
|
|
3486
3524
|
function toPaymentError(err) {
|
|
3487
3525
|
if (err instanceof PaymentError) return err;
|
|
3526
|
+
const response = (err && err.response) || null;
|
|
3527
|
+
const body = (response && response.data) || (err && err.details) || null;
|
|
3488
3528
|
const status =
|
|
3489
|
-
|
|
3490
|
-
?
|
|
3491
|
-
:
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
const
|
|
3495
|
-
|
|
3529
|
+
response && typeof response.status === "number"
|
|
3530
|
+
? response.status
|
|
3531
|
+
: err && typeof err.status === "number" && err.status
|
|
3532
|
+
? err.status
|
|
3533
|
+
: null;
|
|
3534
|
+
const bodyCode = body && body.code;
|
|
3535
|
+
const clientCode = err && err.code;
|
|
3536
|
+
// `message` is the canonical envelope's field (REQ-GEN-05); `error` is the
|
|
3537
|
+
// ad-hoc envelope a few older surfaces still emit.
|
|
3538
|
+
const bodyMessage = body && (body.message || body.error);
|
|
3496
3539
|
let code = "INTERNAL";
|
|
3497
3540
|
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
3541
|
+
else if (typeof clientCode === "string" && clientCode) code = clientCode;
|
|
3498
3542
|
else if (status === 401) code = "AUTH_REQUIRED";
|
|
3499
3543
|
else if (status === 402) code = "DECLINED";
|
|
3500
3544
|
else if (status === 403) code = "FORBIDDEN";
|
package/dist/index.d.ts
CHANGED
|
@@ -766,6 +766,40 @@ export interface PaymentsApi {
|
|
|
766
766
|
*/
|
|
767
767
|
export function usePayments(): PaymentsApi;
|
|
768
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Rejection thrown by both `usePayments()` callbacks. `code` is the server's
|
|
771
|
+
* own reason when it sent one; `message` is its user-safe sentence. Branch on
|
|
772
|
+
* `retryable`: false means the same charge can never succeed until the
|
|
773
|
+
* workspace, the manifest, or the amount changes — show the reason, not a
|
|
774
|
+
* "try again" that loops the payer forever.
|
|
775
|
+
*/
|
|
776
|
+
export class PaymentError extends Error {
|
|
777
|
+
code:
|
|
778
|
+
| "AUTH_REQUIRED"
|
|
779
|
+
| "BUSINESS_IDENTITY_REQUIRED"
|
|
780
|
+
| "PAYMENTS_SCOPE_NOT_GRANTED"
|
|
781
|
+
| "PAYMENTS_UNAVAILABLE"
|
|
782
|
+
| "UNSUPPORTED_CURRENCY"
|
|
783
|
+
| "INVALID_AMOUNT"
|
|
784
|
+
| "AMOUNT_TOO_LARGE"
|
|
785
|
+
| "VALIDATION"
|
|
786
|
+
| "CONNECT_NOT_READY"
|
|
787
|
+
| "PAYMENTS_DISABLED"
|
|
788
|
+
| "DECLINED"
|
|
789
|
+
| "FORBIDDEN"
|
|
790
|
+
| "NOT_FOUND"
|
|
791
|
+
| "RATE_LIMITED"
|
|
792
|
+
| "INTERNAL"
|
|
793
|
+
| string;
|
|
794
|
+
/** False when retrying the same charge can never succeed. */
|
|
795
|
+
retryable: boolean;
|
|
796
|
+
constructor(
|
|
797
|
+
code: string,
|
|
798
|
+
message: string,
|
|
799
|
+
opts?: { cause?: unknown },
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
769
803
|
/**
|
|
770
804
|
* Arguments for `useSendNotification().send(...)`. snake_case VERBATIM — this
|
|
771
805
|
* is the wire contract (REQ-GEN-09). `recipient_user_id`, `title`, and `body`
|
|
@@ -1732,7 +1766,19 @@ export interface LintFinding {
|
|
|
1732
1766
|
line: number;
|
|
1733
1767
|
snippet: string;
|
|
1734
1768
|
}
|
|
1735
|
-
export
|
|
1769
|
+
export interface LintOptions {
|
|
1770
|
+
manifest?: { requestedScopes?: string[]; supportedPlatforms?: string[] };
|
|
1771
|
+
/**
|
|
1772
|
+
* sc-4649 — the currency the target workspace charges its app users in. A
|
|
1773
|
+
* per-workspace value the SDK cannot know, so the `payment-currency` rule
|
|
1774
|
+
* stays silent unless the caller (the backend publish gate) supplies it.
|
|
1775
|
+
*/
|
|
1776
|
+
paymentCurrency?: string;
|
|
1777
|
+
}
|
|
1778
|
+
export function lintSource(
|
|
1779
|
+
source: string,
|
|
1780
|
+
options?: LintOptions,
|
|
1781
|
+
): {
|
|
1736
1782
|
ok: boolean;
|
|
1737
1783
|
findings: LintFinding[];
|
|
1738
1784
|
};
|
package/dist/linter.cjs
CHANGED
|
@@ -728,6 +728,77 @@ function _jsxOpenTagEnd(source, from) {
|
|
|
728
728
|
return source.length;
|
|
729
729
|
}
|
|
730
730
|
|
|
731
|
+
// sc-4649 — see linter.js for the rationale comment. The two files must stay
|
|
732
|
+
// in lockstep (the contract test asserts behaviour-equivalence).
|
|
733
|
+
const REQUEST_PAYMENT_CALL = "requestPayment(";
|
|
734
|
+
// A generous window: the options object is usually inline, occasionally spread
|
|
735
|
+
// over a few lines. Bounded so an unbalanced source can't scan to EOF.
|
|
736
|
+
const REQUEST_PAYMENT_WINDOW = 400;
|
|
737
|
+
const CURRENCY_LITERAL_RE = /currency\s*:\s*(['"`])\s*([A-Za-z]{2,8})\s*\1/;
|
|
738
|
+
|
|
739
|
+
function _paymentCurrencyRules(source, paymentCurrency) {
|
|
740
|
+
const expected = String(paymentCurrency || "").trim().toUpperCase();
|
|
741
|
+
if (!expected) return [];
|
|
742
|
+
const findings = [];
|
|
743
|
+
// Comments blanked, strings kept: the currency code IS a string literal.
|
|
744
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
745
|
+
const sourceLines = source.split(/\r?\n/);
|
|
746
|
+
let from = 0;
|
|
747
|
+
for (;;) {
|
|
748
|
+
const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
|
|
749
|
+
if (at === -1) break;
|
|
750
|
+
from = at + REQUEST_PAYMENT_CALL.length;
|
|
751
|
+
const match = CURRENCY_LITERAL_RE.exec(
|
|
752
|
+
code.slice(at, at + REQUEST_PAYMENT_WINDOW),
|
|
753
|
+
);
|
|
754
|
+
if (!match) continue;
|
|
755
|
+
const found = match[2].toUpperCase();
|
|
756
|
+
if (found === expected) continue;
|
|
757
|
+
// Line of the currency literal itself, not of the call.
|
|
758
|
+
const line = code.slice(0, at + match.index).split(/\r?\n/).length;
|
|
759
|
+
findings.push({
|
|
760
|
+
rule: "payment-currency",
|
|
761
|
+
severity: "error",
|
|
762
|
+
label:
|
|
763
|
+
`requestPayment charges in "${found}" but this workspace sells in ` +
|
|
764
|
+
`${expected}, and the server refuses any other currency, so the ` +
|
|
765
|
+
`checkout can never complete. Omit currency (the platform applies ` +
|
|
766
|
+
`${expected}) or pass exactly "${expected}".`,
|
|
767
|
+
line,
|
|
768
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return findings;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// sc-4650 — soft warning: a widget that charges must tell a failure worth
|
|
775
|
+
// retrying from a refusal only the workspace owner can lift. Collapsing every
|
|
776
|
+
// rejection into one "please try again" is what sent payers round an
|
|
777
|
+
// unbreakable loop on BUSINESS_IDENTITY_REQUIRED. `PaymentError.retryable` (or
|
|
778
|
+
// an explicit `err.code === "…"` branch) is the signal; strings and comments
|
|
779
|
+
// are blanked so prose about retrying never satisfies the check.
|
|
780
|
+
function _paymentErrorHandlingRules(source) {
|
|
781
|
+
const code = _stripNonCode(source);
|
|
782
|
+
const call = /\brequestPayment\s*\(/.exec(code);
|
|
783
|
+
if (!call) return [];
|
|
784
|
+
if (/\bretryable\b/.test(code)) return [];
|
|
785
|
+
if (/\bcode\s*===/.test(code)) return [];
|
|
786
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
787
|
+
return [
|
|
788
|
+
{
|
|
789
|
+
rule: "payment-error-not-branched",
|
|
790
|
+
severity: "warning",
|
|
791
|
+
label:
|
|
792
|
+
`charges with requestPayment() but never reads ` +
|
|
793
|
+
`PaymentError.retryable — a refusal like BUSINESS_IDENTITY_REQUIRED ` +
|
|
794
|
+
`can never succeed on retry, so render err.message instead of a ` +
|
|
795
|
+
`generic "try again".`,
|
|
796
|
+
line,
|
|
797
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
798
|
+
},
|
|
799
|
+
];
|
|
800
|
+
}
|
|
801
|
+
|
|
731
802
|
function _imagePercentHeightRules(source) {
|
|
732
803
|
const findings = [];
|
|
733
804
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
@@ -822,6 +893,11 @@ function lintSource(source, options) {
|
|
|
822
893
|
findings.push(..._lucideIconRules(source));
|
|
823
894
|
findings.push(..._reactInScopeRules(source));
|
|
824
895
|
findings.push(..._imagePercentHeightRules(source));
|
|
896
|
+
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
897
|
+
findings.push(
|
|
898
|
+
..._paymentCurrencyRules(source, options && options.paymentCurrency),
|
|
899
|
+
);
|
|
900
|
+
findings.push(..._paymentErrorHandlingRules(source));
|
|
825
901
|
findings.push(
|
|
826
902
|
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
827
903
|
...f,
|
package/dist/linter.js
CHANGED
|
@@ -846,6 +846,93 @@ function _jsxOpenTagEnd(source, from) {
|
|
|
846
846
|
return source.length;
|
|
847
847
|
}
|
|
848
848
|
|
|
849
|
+
// sc-4649 — payment-currency.
|
|
850
|
+
//
|
|
851
|
+
// Each workspace charges its app users in ONE currency of its own choosing
|
|
852
|
+
// (`Tenant.app_user_price_currency`), and the server refuses a charge in any
|
|
853
|
+
// other with `UNSUPPORTED_CURRENCY`. A widget that hardcodes a different code
|
|
854
|
+
// compiles, renders, and looks finished, but every checkout it runs dies on a
|
|
855
|
+
// 400 — so this is an `error`, not a warning like `payment-error-not-branched`:
|
|
856
|
+
// a literal mismatch is unambiguous and there is no correct code for an opt-out
|
|
857
|
+
// directive to rescue.
|
|
858
|
+
//
|
|
859
|
+
// The expected currency is a PER-WORKSPACE value the SDK cannot know, so this
|
|
860
|
+
// rule only fires when the caller supplies `options.paymentCurrency` — the
|
|
861
|
+
// backend publish gate does, a bare `appstudio-widget lint` does not. There is
|
|
862
|
+
// deliberately no default: guessing one would flag a correct widget.
|
|
863
|
+
//
|
|
864
|
+
// Scoped to the argument of a `requestPayment(...)` call. A `currency` field
|
|
865
|
+
// elsewhere (a datastore column, an `Intl.NumberFormat` option) is the widget's
|
|
866
|
+
// own display concern and is not judged here.
|
|
867
|
+
const REQUEST_PAYMENT_CALL = "requestPayment(";
|
|
868
|
+
// A generous window: the options object is usually inline, occasionally spread
|
|
869
|
+
// over a few lines. Bounded so an unbalanced source can't scan to EOF.
|
|
870
|
+
const REQUEST_PAYMENT_WINDOW = 400;
|
|
871
|
+
const CURRENCY_LITERAL_RE = /currency\s*:\s*(['"`])\s*([A-Za-z]{2,8})\s*\1/;
|
|
872
|
+
|
|
873
|
+
function _paymentCurrencyRules(source, paymentCurrency) {
|
|
874
|
+
const expected = String(paymentCurrency || "").trim().toUpperCase();
|
|
875
|
+
if (!expected) return [];
|
|
876
|
+
const findings = [];
|
|
877
|
+
// Comments blanked, strings kept: the currency code IS a string literal.
|
|
878
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
879
|
+
const sourceLines = source.split(/\r?\n/);
|
|
880
|
+
let from = 0;
|
|
881
|
+
for (;;) {
|
|
882
|
+
const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
|
|
883
|
+
if (at === -1) break;
|
|
884
|
+
from = at + REQUEST_PAYMENT_CALL.length;
|
|
885
|
+
const match = CURRENCY_LITERAL_RE.exec(
|
|
886
|
+
code.slice(at, at + REQUEST_PAYMENT_WINDOW),
|
|
887
|
+
);
|
|
888
|
+
if (!match) continue;
|
|
889
|
+
const found = match[2].toUpperCase();
|
|
890
|
+
if (found === expected) continue;
|
|
891
|
+
// Line of the currency literal itself, not of the call.
|
|
892
|
+
const line = code.slice(0, at + match.index).split(/\r?\n/).length;
|
|
893
|
+
findings.push({
|
|
894
|
+
rule: "payment-currency",
|
|
895
|
+
severity: "error",
|
|
896
|
+
label:
|
|
897
|
+
`requestPayment charges in "${found}" but this workspace sells in ` +
|
|
898
|
+
`${expected}, and the server refuses any other currency, so the ` +
|
|
899
|
+
`checkout can never complete. Omit currency (the platform applies ` +
|
|
900
|
+
`${expected}) or pass exactly "${expected}".`,
|
|
901
|
+
line,
|
|
902
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
return findings;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// sc-4650 — soft warning: a widget that charges must tell a failure worth
|
|
909
|
+
// retrying from a refusal only the workspace owner can lift. Collapsing every
|
|
910
|
+
// rejection into one "please try again" is what sent payers round an
|
|
911
|
+
// unbreakable loop on BUSINESS_IDENTITY_REQUIRED. `PaymentError.retryable` (or
|
|
912
|
+
// an explicit `err.code === "…"` branch) is the signal; strings and comments
|
|
913
|
+
// are blanked so prose about retrying never satisfies the check.
|
|
914
|
+
function _paymentErrorHandlingRules(source) {
|
|
915
|
+
const code = _stripNonCode(source);
|
|
916
|
+
const call = /\brequestPayment\s*\(/.exec(code);
|
|
917
|
+
if (!call) return [];
|
|
918
|
+
if (/\bretryable\b/.test(code)) return [];
|
|
919
|
+
if (/\bcode\s*===/.test(code)) return [];
|
|
920
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
921
|
+
return [
|
|
922
|
+
{
|
|
923
|
+
rule: "payment-error-not-branched",
|
|
924
|
+
severity: "warning",
|
|
925
|
+
label:
|
|
926
|
+
`charges with requestPayment() but never reads ` +
|
|
927
|
+
`PaymentError.retryable — a refusal like BUSINESS_IDENTITY_REQUIRED ` +
|
|
928
|
+
`can never succeed on retry, so render err.message instead of a ` +
|
|
929
|
+
`generic "try again".`,
|
|
930
|
+
line,
|
|
931
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
932
|
+
},
|
|
933
|
+
];
|
|
934
|
+
}
|
|
935
|
+
|
|
849
936
|
function _imagePercentHeightRules(source) {
|
|
850
937
|
const findings = [];
|
|
851
938
|
// Comments are blanked (string contents kept) so a commented-out example —
|
|
@@ -952,6 +1039,11 @@ export function lintSource(source, options) {
|
|
|
952
1039
|
findings.push(..._reactInScopeRules(source));
|
|
953
1040
|
// sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
|
|
954
1041
|
findings.push(..._imagePercentHeightRules(source));
|
|
1042
|
+
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
1043
|
+
findings.push(
|
|
1044
|
+
..._paymentCurrencyRules(source, options && options.paymentCurrency),
|
|
1045
|
+
);
|
|
1046
|
+
findings.push(..._paymentErrorHandlingRules(source));
|
|
955
1047
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
956
1048
|
// line-by-line scan so banned-identifier findings stay first in the
|
|
957
1049
|
// output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.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-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-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.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__/hooks-translate.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 src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.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-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-payment-error.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__/hooks-translate.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 src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|