@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.
@@ -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:<ref>` | `ctx.secrets.get('<ref>')` for that one secret ref. |
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. If you don't declare a `secrets:<ref>`,
173
- `ctx.secrets.get('<ref>')` returns `null`.
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:<ref>`). `get(ref)` resolves the collection's OWN
190
- * secret first, then the app-level one; `app(ref)` reads the app-level secret only. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.40",
3
+ "version": "2.0.42",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
- const manifestPath = ['public/app.manifest.json', 'app.manifest.json', 'dist/app.manifest.json']
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/, ., dist/)`);
53
+ if (!manifestPath) die(`no app.manifest.json found under ${appDir} (looked in dist/, public/, .)`);
51
54
 
52
55
  let manifest;
53
56
  try {