@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.
- package/dist/api/secrets.d.ts +21 -1
- package/dist/api/secrets.js +40 -6
- package/dist/docs/API_SUMMARY.md +21 -2
- package/dist/docs/integrations.md +1 -1
- package/dist/docs/server-functions.md +101 -5
- package/dist/openapi.yaml +7 -0
- package/dist/types/appManifest.d.ts +6 -1
- package/docs/API_SUMMARY.md +21 -2
- package/docs/integrations.md +1 -1
- package/docs/server-functions.md +101 -5
- package/openapi.yaml +7 -0
- package/package.json +1 -1
- package/scripts/doctor.mjs +6 -3
package/dist/api/secrets.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|
package/dist/api/secrets.js
CHANGED
|
@@ -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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
78
|
+
return put_(`${base(collectionId)}/${enc(ref)}`, input);
|
|
45
79
|
}
|
|
46
80
|
secrets.rotate = rotate;
|
|
47
81
|
/** Soft-delete a secret. DELETE /secrets/:ref */
|
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.0.
|
|
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:<
|
|
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. */
|
|
@@ -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
|
-
/**
|
|
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']`). */
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.0.
|
|
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
|
package/docs/integrations.md
CHANGED
|
@@ -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` |
|
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. */
|
|
@@ -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
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 {
|