@proveanything/smartlinks 2.0.40 → 2.0.42
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/dist/api/secrets.d.ts +21 -1
- package/dist/api/secrets.js +40 -6
- package/dist/docs/API_SUMMARY.md +43 -2
- package/dist/docs/iframe-responder.md +310 -308
- package/dist/docs/integrations.md +1 -1
- package/dist/docs/server-functions.md +135 -5
- package/dist/http.d.ts +5 -0
- package/dist/http.js +28 -4
- package/dist/openapi.yaml +39 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.js +1 -0
- package/dist/types/appManifest.d.ts +37 -1
- package/docs/API_SUMMARY.md +43 -2
- package/docs/iframe-responder.md +310 -308
- package/docs/integrations.md +1 -1
- package/docs/server-functions.md +135 -5
- package/openapi.yaml +39 -0
- package/package.json +1 -1
- package/scripts/doctor.mjs +6 -3
package/docs/server-functions.md
CHANGED
|
@@ -167,10 +167,10 @@ Declare the minimum you need.
|
|
|
167
167
|
| `sl:<resource>:read` / `sl:<resource>:write` | SDK access to that resource, e.g. `sl:products:read`, `sl:records:write`, `sl:attestations:write`, `sl:contacts:write`. |
|
|
168
168
|
| `network` | `ctx.fetch` to any host. |
|
|
169
169
|
| `network:<host>` | `ctx.fetch` to that host only (repeat for several). Prefer this over blanket `network`. |
|
|
170
|
-
| `secrets:<
|
|
170
|
+
| `secrets:<name>` | `ctx.secrets.get('<name>')` for that one secret (see [Secrets](#secrets--api-keys-per-collection)). |
|
|
171
171
|
|
|
172
|
-
If you don't declare `network`, `ctx.fetch` is absent.
|
|
173
|
-
`
|
|
172
|
+
If you don't declare `network`, `ctx.fetch` is absent. Reading a secret you didn't declare with
|
|
173
|
+
`secrets:<name>` throws.
|
|
174
174
|
|
|
175
175
|
---
|
|
176
176
|
|
|
@@ -186,8 +186,8 @@ interface ServerFunctionContext {
|
|
|
186
186
|
* data — URLs, flags, counters, arrays), `sl.products`, `sl.attestations`. */
|
|
187
187
|
sl: SmartLinks
|
|
188
188
|
|
|
189
|
-
/** Capability-gated secrets (need `secrets:<
|
|
190
|
-
*
|
|
189
|
+
/** Capability-gated secrets (need `secrets:<name>`). `get(name)` reads this app's own secret on the
|
|
190
|
+
* collection first, then the app-level one; `app(name)` reads the app-level secret only. */
|
|
191
191
|
secrets: { get(ref: string): Promise<string | null>; app(ref: string): Promise<string | null> }
|
|
192
192
|
|
|
193
193
|
/** Who invoked this function. */
|
|
@@ -324,6 +324,40 @@ name (a first-party builtin still wins). Always prefer an appId.
|
|
|
324
324
|
|
|
325
325
|
---
|
|
326
326
|
|
|
327
|
+
## Inputs — data handed to your function
|
|
328
|
+
|
|
329
|
+
Declare what your function needs and the platform fetches it **before** the run: read it from `ctx.inputs` instead of calling `ctx.sl` first. One less round trip — and for functions running in the isolated runner, one less network hop.
|
|
330
|
+
|
|
331
|
+
```jsonc
|
|
332
|
+
// app.manifest.json → functions.definitions[]
|
|
333
|
+
{
|
|
334
|
+
"name": "onProductChanged",
|
|
335
|
+
"trigger": { "type": "event", "eventTypes": ["product.updated"] },
|
|
336
|
+
"capabilities": ["sl:products:read", "sl:data:read"],
|
|
337
|
+
"inputs": { "appConfig": true } // entity is on by default for event triggers
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
export async function onProductChanged(ctx, event) {
|
|
343
|
+
const product = ctx.inputs.entity // the product that changed
|
|
344
|
+
const { interactionIds } = ctx.inputs.appConfig || {} // this app's config for the collection
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
| Input | What you get | Needs | Triggers |
|
|
349
|
+
|---|---|---|---|
|
|
350
|
+
| `entity` | The record the event is about: the product, your app's record, or the interaction event. **On by default**; `"entity": false` opts out. | read on that resource (`sl:products:read`, `sl:records:read`) | event |
|
|
351
|
+
| `collection` | The collection's public record | — | all |
|
|
352
|
+
| `appConfig` | Your app's config for this collection (where setup like `interactionIds` lives) | `sl:data:read` | all |
|
|
353
|
+
| `product` | A product named by the request: `{ "from": "body.productId" }`, `"query.<field>"` or `"path"` | `sl:products:read` | http |
|
|
354
|
+
|
|
355
|
+
- **Same rules as `ctx.sl`.** Inputs are fetched with your function's authority and capabilities, scoped to the collection it runs in — a product from another collection is never fetched.
|
|
356
|
+
- **Never an error.** Not found, not allowed or larger than 256 KB → that input is `null`. Check before you use it.
|
|
357
|
+
- **Current state.** Event inputs are fetched when your function runs, so you see the record as it is now; `event` still carries the ids.
|
|
358
|
+
- **Checked at publish.** An unknown input, a bad `from`, or a missing capability fails validation.
|
|
359
|
+
- **Testing:** pass `inputs` to the test context — `createFunctionTestContext({ def, inputs: { entity: { id: 'p1', name: 'Kettle' } } })`.
|
|
360
|
+
|
|
327
361
|
## Exposing a function to the AI agent
|
|
328
362
|
|
|
329
363
|
An `http` function can be offered to the AI agent as a **callable tool**, alongside the built-in
|
|
@@ -527,6 +561,102 @@ other secrets, or any other collection.
|
|
|
527
561
|
|
|
528
562
|
---
|
|
529
563
|
|
|
564
|
+
## Secrets — API keys per collection
|
|
565
|
+
|
|
566
|
+
A function that calls another system needs that system's key, and each collection (each customer)
|
|
567
|
+
has its own. Your app's **admin screen** saves it; your **functions** read it by the same name.
|
|
568
|
+
|
|
569
|
+
**1. Declare it** on every function that reads it:
|
|
570
|
+
|
|
571
|
+
```jsonc
|
|
572
|
+
{ "name": "pullOrders", "trigger": { "type": "cron", "schedule": "0,30 * * * *" },
|
|
573
|
+
"capabilities": ["network:api.example-shop.com", "secrets:shop-api-key"] }
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
**2. Save it from your admin screen**, signed in as a collection admin:
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
await SL.secrets.put(collectionId, 'shop-api-key', apiKeyFromTheForm) // create or replace
|
|
580
|
+
const { secrets } = await SL.secrets.listOwn(collectionId) // names + masked hints, e.g. "••••1234"
|
|
581
|
+
await SL.secrets.removeOwn(collectionId, 'shop-api-key')
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The app comes from the SDK's app context (`initializeApi({ appId })`), or pass `{ appId }` as the
|
|
585
|
+
last argument. Secrets are write-only: nothing ever returns the value, so show the hint ("saved,
|
|
586
|
+
ends 1234") and offer "replace", not "edit". Build this into your own settings screen; the platform
|
|
587
|
+
doesn't prompt for it at install.
|
|
588
|
+
|
|
589
|
+
**3. Read it in the function:**
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
const key = await ctx.secrets.get('shop-api-key') // null until an admin has saved it
|
|
593
|
+
if (!key) { ctx.log('shop not connected yet'); return { skipped: 'not connected' } }
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Each app's secrets are its own.** `shop-api-key` saved by your app and `shop-api-key` saved by
|
|
597
|
+
another app on the same collection are two different secrets; your functions only ever read yours.
|
|
598
|
+
|
|
599
|
+
`ctx.secrets.get(name)` looks in this order:
|
|
600
|
+
1. your app's secret on this collection (saved with `SL.secrets.put`);
|
|
601
|
+
2. *SmartLinks' own apps only:* a collection-wide secret of that name. Third-party apps never read these;
|
|
602
|
+
3. your app's shared, app-level secret (the same for every collection), if Forge has one for it.
|
|
603
|
+
|
|
604
|
+
Don't use `SL.secrets.set` for this. It creates a collection secret with a **generated** ref
|
|
605
|
+
(`sec_…`) for integration flows, which a function can't name in its manifest.
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Scheduled functions (cron)
|
|
610
|
+
|
|
611
|
+
A `cron` function runs on a schedule, on **every collection that has the app installed**, with
|
|
612
|
+
`collection` authority (there's no caller). It's the shape for polling another system ("pull new
|
|
613
|
+
orders every 15 minutes"), nightly reconciles, digests and clean-ups.
|
|
614
|
+
|
|
615
|
+
```jsonc
|
|
616
|
+
{
|
|
617
|
+
"name": "pullOrders",
|
|
618
|
+
"trigger": { "type": "cron", "schedule": "*/15 * * * *" },
|
|
619
|
+
"capabilities": ["sl:records:write", "network:api.example-shop.com", "secrets:shop-api-key"]
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
"name": "morningDigest",
|
|
623
|
+
"trigger": { "type": "cron", "schedule": "0 9 * * MON-FRI", "timezone": "Europe/London" },
|
|
624
|
+
"capabilities": ["sl:records:read", "network:hooks.slack.com", "secrets:slack-webhook"]
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
- **`schedule`** — standard 5-field cron: minute, hour, day-of-month, month, day-of-week. Lists,
|
|
629
|
+
ranges and steps (`0,30`, `9-17`, `*/15`), month and day names (`JAN`, `MON-FRI`; Sunday is 0 or 7),
|
|
630
|
+
and `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly`. When both day-of-month and
|
|
631
|
+
day-of-week are restricted, a day matching either fires (as in cron).
|
|
632
|
+
- **`timezone`** *(optional)* — an IANA zone like `"Europe/London"`; the schedule follows its
|
|
633
|
+
summer time. Default UTC.
|
|
634
|
+
- **At most every 5 minutes.** A faster schedule, a bad expression or an unknown zone fails the
|
|
635
|
+
release (`SCHEDULE_INVALID`), so it never silently doesn't run.
|
|
636
|
+
|
|
637
|
+
**What runs:** each installation runs the current release of the channel it's installed on, so a
|
|
638
|
+
test collection with the app on `dev` runs your dev build on its schedule. Disabled installs and
|
|
639
|
+
expired dev installs don't run.
|
|
640
|
+
|
|
641
|
+
**The event** your handler receives:
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
{ type: 'cron', schedule: '*/15 * * * *', timezone: null, scheduledAt: '2026-10-06T10:15:00.000Z' }
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
`scheduledAt` is the minute the run was due, which is not always the moment it starts: runs queue,
|
|
648
|
+
and a late one still carries its own minute. Use it, not `Date.now()`, as the end of the window
|
|
649
|
+
you're syncing.
|
|
650
|
+
|
|
651
|
+
**Write it to be safe to run twice.** Each (collection, function, minute) is enqueued once, but a
|
|
652
|
+
run that fails part-way can be retried, and a slow run can still be going when the next one starts.
|
|
653
|
+
Keep a cursor (last `scheduledAt` or the other system's own cursor) in the app's records or config,
|
|
654
|
+
and upsert by the other system's ID instead of inserting blindly.
|
|
655
|
+
|
|
656
|
+
Each run is logged like any other function run, and a failure shows in the collection's Errors view.
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
530
660
|
## Invoking an http function
|
|
531
661
|
|
|
532
662
|
An `http` function is called by POSTing to the app-scoped functions endpoint on the
|
package/openapi.yaml
CHANGED
|
@@ -19086,6 +19086,8 @@ components:
|
|
|
19086
19086
|
type: string
|
|
19087
19087
|
schedule:
|
|
19088
19088
|
type: string
|
|
19089
|
+
timezone:
|
|
19090
|
+
type: string
|
|
19089
19091
|
route:
|
|
19090
19092
|
type: string
|
|
19091
19093
|
methods:
|
|
@@ -19124,6 +19126,8 @@ components:
|
|
|
19124
19126
|
enum:
|
|
19125
19127
|
- collection
|
|
19126
19128
|
- global
|
|
19129
|
+
inputs:
|
|
19130
|
+
$ref: "#/components/schemas/AppFunctionInputs"
|
|
19127
19131
|
apiVersion:
|
|
19128
19132
|
type: string
|
|
19129
19133
|
handler:
|
|
@@ -19233,6 +19237,8 @@ components:
|
|
|
19233
19237
|
fetch:
|
|
19234
19238
|
type: object
|
|
19235
19239
|
additionalProperties: true
|
|
19240
|
+
inputs:
|
|
19241
|
+
$ref: "#/components/schemas/ServerFunctionInputs"
|
|
19236
19242
|
required:
|
|
19237
19243
|
- collectionId
|
|
19238
19244
|
- appId
|
|
@@ -19240,6 +19246,34 @@ components:
|
|
|
19240
19246
|
- secrets
|
|
19241
19247
|
- caller
|
|
19242
19248
|
- fetch
|
|
19249
|
+
- inputs
|
|
19250
|
+
AppFunctionInputs:
|
|
19251
|
+
type: object
|
|
19252
|
+
properties:
|
|
19253
|
+
entity:
|
|
19254
|
+
type: boolean
|
|
19255
|
+
collection:
|
|
19256
|
+
type: boolean
|
|
19257
|
+
appConfig:
|
|
19258
|
+
type: boolean
|
|
19259
|
+
product:
|
|
19260
|
+
type: object
|
|
19261
|
+
additionalProperties: true
|
|
19262
|
+
ServerFunctionInputs:
|
|
19263
|
+
type: object
|
|
19264
|
+
properties:
|
|
19265
|
+
entity:
|
|
19266
|
+
type: object
|
|
19267
|
+
additionalProperties: true
|
|
19268
|
+
collection:
|
|
19269
|
+
type: object
|
|
19270
|
+
additionalProperties: true
|
|
19271
|
+
appConfig:
|
|
19272
|
+
type: object
|
|
19273
|
+
additionalProperties: true
|
|
19274
|
+
product:
|
|
19275
|
+
type: object
|
|
19276
|
+
additionalProperties: true
|
|
19243
19277
|
ServerFunctionHttpEvent:
|
|
19244
19278
|
type: object
|
|
19245
19279
|
properties:
|
|
@@ -30749,6 +30783,11 @@ components:
|
|
|
30749
30783
|
type: string
|
|
30750
30784
|
host:
|
|
30751
30785
|
type: string
|
|
30786
|
+
AppSecretOptions:
|
|
30787
|
+
type: object
|
|
30788
|
+
properties:
|
|
30789
|
+
appId:
|
|
30790
|
+
type: string
|
|
30752
30791
|
AllocateSequenceInput:
|
|
30753
30792
|
type: object
|
|
30754
30793
|
properties:
|
package/package.json
CHANGED
package/scripts/doctor.mjs
CHANGED
|
@@ -43,11 +43,14 @@ function die(msg) {
|
|
|
43
43
|
const appDir = resolve(process.argv[2] || process.cwd());
|
|
44
44
|
if (!existsSync(appDir)) die(`app directory not found: ${appDir}`);
|
|
45
45
|
|
|
46
|
-
// Locate the manifest.
|
|
47
|
-
|
|
46
|
+
// Locate the manifest. The BUILT one (dist/) first: it's what ships, and the build's hash step renames
|
|
47
|
+
// bundles (mobile-admin.esm.js → mobile-admin-<hash>.esm.js) and rewrites only dist/app.manifest.json —
|
|
48
|
+
// checking the source manifest after a build looked for files that no longer exist under that name.
|
|
49
|
+
// The source manifest is the fallback for a doctor run before any build.
|
|
50
|
+
const manifestPath = ['dist/app.manifest.json', 'public/app.manifest.json', 'app.manifest.json']
|
|
48
51
|
.map((p) => join(appDir, p))
|
|
49
52
|
.find(existsSync);
|
|
50
|
-
if (!manifestPath) die(`no app.manifest.json found under ${appDir} (looked in public/,
|
|
53
|
+
if (!manifestPath) die(`no app.manifest.json found under ${appDir} (looked in dist/, public/, .)`);
|
|
51
54
|
|
|
52
55
|
let manifest;
|
|
53
56
|
try {
|