@proveanything/smartlinks 2.0.41 → 2.0.43

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.
@@ -1,8 +1,28 @@
1
1
  import type { SecretMeta, SecretList, SetSecretInput, SetSecretResult, ListSecretsQuery } from "../types/integrations.js";
2
+ export interface AppSecretOptions {
3
+ /** The app whose secret this is. Defaults to the SDK's app context (`initializeApi({ appId })`). */
4
+ appId?: string;
5
+ }
2
6
  export declare namespace secrets {
7
+ /**
8
+ * Save this app's secret `name` on the collection (create or replace) — what the app's server
9
+ * functions read with `ctx.secrets.get(name)` (declare `secrets:<name>` in the manifest). Call it
10
+ * from your admin screen as a collection admin. Write-only: returns `{ ref: name, hint }`, never
11
+ * the value. PUT /app/:appId/secrets/:name
12
+ */
13
+ function put(collectionId: string, name: string, value: string, opts?: AppSecretOptions): Promise<SetSecretResult>;
14
+ /** This app's secrets on the collection: names + masked hints (never values). GET /app/:appId/secrets */
15
+ function listOwn(collectionId: string, opts?: AppSecretOptions): Promise<SecretList>;
16
+ /** Delete this app's secret `name`. DELETE /app/:appId/secrets/:name */
17
+ function removeOwn(collectionId: string, name: string, opts?: AppSecretOptions): Promise<{
18
+ deleted: boolean;
19
+ }>;
3
20
  /** List secrets as refs + masked hints + metadata (never values). GET /secrets */
4
21
  function list(collectionId: string, query?: ListSecretsQuery): Promise<SecretList>;
5
- /** Create a secret. POST /secrets → { ref, hint }. Store the ref on a flow. */
22
+ /**
23
+ * Create a collection secret with a GENERATED ref. POST /secrets → { ref, hint }. Store the ref on a
24
+ * flow. For a secret your app's server functions read by name, use `put` instead.
25
+ */
6
26
  function set(collectionId: string, input: SetSecretInput): Promise<SetSecretResult>;
7
27
  /** Metadata for one secret (never the value). GET /secrets/:ref */
8
28
  function get(collectionId: string, ref: string): Promise<SecretMeta>;
@@ -5,11 +5,15 @@
5
5
  // (refs + masked hints + metadata) and delete, but a value NEVER comes back over the
6
6
  // API. It is sealed at rest and resolved server-side only, at execution time.
7
7
  //
8
- // Typical use: `set` a credential, take the returned `ref`, and put it on a flow's
9
- // config.connection.auth.credentialRef.
8
+ // Two kinds:
9
+ // - an APP's own secret (`put` / `listOwn` / `removeOwn`): saved by name from the app's admin
10
+ // screen, read by that app's server functions with ctx.secrets.get('<name>'). Another app's
11
+ // secret of the same name is a different secret.
12
+ // - a collection secret (`set` / `rotate` / ...): `set` returns a generated `ref` to put on an
13
+ // integration flow's config.connection.auth.credentialRef.
10
14
  //
11
- // Endpoints: /admin/collection/:collectionId/secrets
12
- import { request, post, put, del } from "../http.js";
15
+ // Endpoints: /admin/collection/:collectionId/app/:appId/secrets, /admin/collection/:collectionId/secrets
16
+ import { request, post, put as put_, del, getAppContext } from "../http.js";
13
17
  function enc(v) { return encodeURIComponent(v); }
14
18
  function encodeQuery(params = {}) {
15
19
  const search = new URLSearchParams();
@@ -24,12 +28,42 @@ function encodeQuery(params = {}) {
24
28
  export var secrets;
25
29
  (function (secrets) {
26
30
  const base = (collectionId) => `/admin/collection/${enc(collectionId)}/secrets`;
31
+ const appBase = (collectionId, opts) => {
32
+ var _a;
33
+ const app = (_a = opts.appId) !== null && _a !== void 0 ? _a : getAppContext();
34
+ if (!app)
35
+ throw new Error("secrets: no app — pass { appId } or initializeApi({ appId })");
36
+ return `/admin/collection/${enc(collectionId)}/app/${enc(app)}/secrets`;
37
+ };
38
+ /**
39
+ * Save this app's secret `name` on the collection (create or replace) — what the app's server
40
+ * functions read with `ctx.secrets.get(name)` (declare `secrets:<name>` in the manifest). Call it
41
+ * from your admin screen as a collection admin. Write-only: returns `{ ref: name, hint }`, never
42
+ * the value. PUT /app/:appId/secrets/:name
43
+ */
44
+ async function put(collectionId, name, value, opts = {}) {
45
+ return put_(`${appBase(collectionId, opts)}/${enc(name)}`, { value });
46
+ }
47
+ secrets.put = put;
48
+ /** This app's secrets on the collection: names + masked hints (never values). GET /app/:appId/secrets */
49
+ async function listOwn(collectionId, opts = {}) {
50
+ return request(appBase(collectionId, opts));
51
+ }
52
+ secrets.listOwn = listOwn;
53
+ /** Delete this app's secret `name`. DELETE /app/:appId/secrets/:name */
54
+ async function removeOwn(collectionId, name, opts = {}) {
55
+ return del(`${appBase(collectionId, opts)}/${enc(name)}`);
56
+ }
57
+ secrets.removeOwn = removeOwn;
27
58
  /** List secrets as refs + masked hints + metadata (never values). GET /secrets */
28
59
  async function list(collectionId, query = {}) {
29
60
  return request(`${base(collectionId)}${encodeQuery(query)}`);
30
61
  }
31
62
  secrets.list = list;
32
- /** Create a secret. POST /secrets → { ref, hint }. Store the ref on a flow. */
63
+ /**
64
+ * Create a collection secret with a GENERATED ref. POST /secrets → { ref, hint }. Store the ref on a
65
+ * flow. For a secret your app's server functions read by name, use `put` instead.
66
+ */
33
67
  async function set(collectionId, input) {
34
68
  return post(base(collectionId), input);
35
69
  }
@@ -41,7 +75,7 @@ export var secrets;
41
75
  secrets.get = get;
42
76
  /** Rotate/update a secret's value (and optionally name/purpose). PUT /secrets/:ref → { ref, hint } */
43
77
  async function rotate(collectionId, ref, input) {
44
- return put(`${base(collectionId)}/${enc(ref)}`, input);
78
+ return put_(`${base(collectionId)}/${enc(ref)}`, input);
45
79
  }
46
80
  secrets.rotate = rotate;
47
81
  /** Soft-delete a secret. DELETE /secrets/:ref */
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.41 | Generated: 2026-10-06T07:00:42.791Z
3
+ Version: 2.0.43 | Generated: 2026-10-06T15:37:31.849Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2523,6 +2523,7 @@ interface AppFunctionTrigger {
2523
2523
  type: AppFunctionTriggerType;
2524
2524
  eventTypes?: string[];
2525
2525
  schedule?: string;
2526
+ timezone?: string;
2526
2527
  route?: string;
2527
2528
  methods?: Array<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>;
2528
2529
  }
@@ -9719,6 +9720,15 @@ interface FunctionSiteUrlOptions {
9719
9720
 
9720
9721
  **FunctionCallResult** = `any`
9721
9722
 
9723
+ ### secrets (api)
9724
+
9725
+ **AppSecretOptions** (interface)
9726
+ ```typescript
9727
+ interface AppSecretOptions {
9728
+ appId?: string
9729
+ }
9730
+ ```
9731
+
9722
9732
  ### sequence (api)
9723
9733
 
9724
9734
  **AllocateSequenceInput** (interface)
@@ -12048,11 +12058,20 @@ Fetch + extract a web page: clean markdown, page metadata, and any schema.org JS
12048
12058
 
12049
12059
  ### secrets
12050
12060
 
12061
+ **put**(collectionId: string, name: string, value: string, opts: AppSecretOptions = {}) → `Promise<SetSecretResult>`
12062
+ Save this app's secret `name` on the collection (create or replace) — what the app's server functions read with `ctx.secrets.get(name)` (declare `secrets:<name>` in the manifest). Call it from your admin screen as a collection admin. Write-only: returns `{ ref: name, hint }`, never the value. PUT /app/:appId/secrets/:name
12063
+
12064
+ **listOwn**(collectionId: string, opts: AppSecretOptions = {}) → `Promise<SecretList>`
12065
+ This app's secrets on the collection: names + masked hints (never values). GET /app/:appId/secrets
12066
+
12067
+ **removeOwn**(collectionId: string, name: string, opts: AppSecretOptions = {}) → `Promise<`
12068
+ Delete this app's secret `name`. DELETE /app/:appId/secrets/:name
12069
+
12051
12070
  **list**(collectionId: string, query: ListSecretsQuery = {}) → `Promise<SecretList>`
12052
12071
  List secrets as refs + masked hints + metadata (never values). GET /secrets
12053
12072
 
12054
12073
  **set**(collectionId: string, input: SetSecretInput) → `Promise<SetSecretResult>`
12055
- Create a secret. POST /secrets → { ref, hint }. Store the ref on a flow.
12074
+ Create a collection secret with a GENERATED ref. POST /secrets → { ref, hint }. Store the ref on a flow. For a secret your app's server functions read by name, use `put` instead.
12056
12075
 
12057
12076
  **get**(collectionId: string, ref: string) → `Promise<SecretMeta>`
12058
12077
  Metadata for one secret (never the value). GET /secrets/:ref
@@ -135,7 +135,7 @@ admin API fires the flow automatically — no manual run needed.
135
135
  | `integrations.deleteFlow(collectionId, id)` | `DELETE /integrations/flows/:id` |
136
136
  | `integrations.runFlow(collectionId, id, opts?)` | `POST /integrations/flows/:id/run` |
137
137
  | `secrets.list(collectionId, query?)` | `GET /secrets` |
138
- | `secrets.set(collectionId, input)` | `POST /secrets` |
138
+ | `secrets.set(collectionId, input)` | `POST /secrets` (generated ref — for flows; an app's server functions use `secrets.put`, see server-functions.md) |
139
139
  | `secrets.get(collectionId, ref)` | `GET /secrets/:ref` |
140
140
  | `secrets.rotate(collectionId, ref, input)` | `PUT /secrets/:ref` |
141
141
  | `secrets.remove(collectionId, ref)` | `DELETE /secrets/:ref` |
@@ -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. */
@@ -561,6 +561,102 @@ other secrets, or any other collection.
561
561
 
562
562
  ---
563
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
+
564
660
  ## Invoking an http function
565
661
 
566
662
  An `http` function is called by POSTing to the app-scoped functions endpoint on the
package/dist/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:
@@ -30781,6 +30783,11 @@ components:
30781
30783
  type: string
30782
30784
  host:
30783
30785
  type: string
30786
+ AppSecretOptions:
30787
+ type: object
30788
+ properties:
30789
+ appId:
30790
+ type: string
30784
30791
  AllocateSequenceInput:
30785
30792
  type: object
30786
30793
  properties:
@@ -213,8 +213,13 @@ export interface AppFunctionTrigger {
213
213
  type: AppFunctionTriggerType;
214
214
  /** `event`: event types this function subscribes to, e.g. `['interaction.submitted:comp-entry']`. */
215
215
  eventTypes?: string[];
216
- /** `cron`: standard 5-field crontab expression, evaluated in UTC. */
216
+ /**
217
+ * `cron`: standard 5-field crontab expression (`"0,30 * * * *"`, `"0 9 * * MON-FRI"`, or `@hourly` /
218
+ * `@daily` / `@weekly` / `@monthly`), evaluated in UTC unless `timezone` is set. At most every 5 minutes.
219
+ */
217
220
  schedule?: string;
221
+ /** `cron`: IANA time zone the schedule is read in, e.g. `"Europe/London"` (follows summer time). Default UTC. */
222
+ timezone?: string;
218
223
  /** `http`: URL path segment the function is exposed at (defaults to the function `name`). */
219
224
  route?: string;
220
225
  /** `http`: accepted HTTP methods (defaults to `['POST']`). */
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.41 | Generated: 2026-10-06T07:00:42.791Z
3
+ Version: 2.0.43 | Generated: 2026-10-06T15:37:31.849Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2523,6 +2523,7 @@ interface AppFunctionTrigger {
2523
2523
  type: AppFunctionTriggerType;
2524
2524
  eventTypes?: string[];
2525
2525
  schedule?: string;
2526
+ timezone?: string;
2526
2527
  route?: string;
2527
2528
  methods?: Array<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>;
2528
2529
  }
@@ -9719,6 +9720,15 @@ interface FunctionSiteUrlOptions {
9719
9720
 
9720
9721
  **FunctionCallResult** = `any`
9721
9722
 
9723
+ ### secrets (api)
9724
+
9725
+ **AppSecretOptions** (interface)
9726
+ ```typescript
9727
+ interface AppSecretOptions {
9728
+ appId?: string
9729
+ }
9730
+ ```
9731
+
9722
9732
  ### sequence (api)
9723
9733
 
9724
9734
  **AllocateSequenceInput** (interface)
@@ -12048,11 +12058,20 @@ Fetch + extract a web page: clean markdown, page metadata, and any schema.org JS
12048
12058
 
12049
12059
  ### secrets
12050
12060
 
12061
+ **put**(collectionId: string, name: string, value: string, opts: AppSecretOptions = {}) → `Promise<SetSecretResult>`
12062
+ Save this app's secret `name` on the collection (create or replace) — what the app's server functions read with `ctx.secrets.get(name)` (declare `secrets:<name>` in the manifest). Call it from your admin screen as a collection admin. Write-only: returns `{ ref: name, hint }`, never the value. PUT /app/:appId/secrets/:name
12063
+
12064
+ **listOwn**(collectionId: string, opts: AppSecretOptions = {}) → `Promise<SecretList>`
12065
+ This app's secrets on the collection: names + masked hints (never values). GET /app/:appId/secrets
12066
+
12067
+ **removeOwn**(collectionId: string, name: string, opts: AppSecretOptions = {}) → `Promise<`
12068
+ Delete this app's secret `name`. DELETE /app/:appId/secrets/:name
12069
+
12051
12070
  **list**(collectionId: string, query: ListSecretsQuery = {}) → `Promise<SecretList>`
12052
12071
  List secrets as refs + masked hints + metadata (never values). GET /secrets
12053
12072
 
12054
12073
  **set**(collectionId: string, input: SetSecretInput) → `Promise<SetSecretResult>`
12055
- Create a secret. POST /secrets → { ref, hint }. Store the ref on a flow.
12074
+ Create a collection secret with a GENERATED ref. POST /secrets → { ref, hint }. Store the ref on a flow. For a secret your app's server functions read by name, use `put` instead.
12056
12075
 
12057
12076
  **get**(collectionId: string, ref: string) → `Promise<SecretMeta>`
12058
12077
  Metadata for one secret (never the value). GET /secrets/:ref
@@ -135,7 +135,7 @@ admin API fires the flow automatically — no manual run needed.
135
135
  | `integrations.deleteFlow(collectionId, id)` | `DELETE /integrations/flows/:id` |
136
136
  | `integrations.runFlow(collectionId, id, opts?)` | `POST /integrations/flows/:id/run` |
137
137
  | `secrets.list(collectionId, query?)` | `GET /secrets` |
138
- | `secrets.set(collectionId, input)` | `POST /secrets` |
138
+ | `secrets.set(collectionId, input)` | `POST /secrets` (generated ref — for flows; an app's server functions use `secrets.put`, see server-functions.md) |
139
139
  | `secrets.get(collectionId, ref)` | `GET /secrets/:ref` |
140
140
  | `secrets.rotate(collectionId, ref, input)` | `PUT /secrets/:ref` |
141
141
  | `secrets.remove(collectionId, ref)` | `DELETE /secrets/:ref` |
@@ -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. */
@@ -561,6 +561,102 @@ other secrets, or any other collection.
561
561
 
562
562
  ---
563
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
+
564
660
  ## Invoking an http function
565
661
 
566
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:
@@ -30781,6 +30783,11 @@ components:
30781
30783
  type: string
30782
30784
  host:
30783
30785
  type: string
30786
+ AppSecretOptions:
30787
+ type: object
30788
+ properties:
30789
+ appId:
30790
+ type: string
30784
30791
  AllocateSequenceInput:
30785
30792
  type: object
30786
30793
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.41",
3
+ "version": "2.0.43",
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 {