@proveanything/smartlinks 2.0.34 → 2.0.36
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/ai-tools.d.ts +3 -0
- package/dist/ai-tools.js +9 -0
- package/dist/api/collection.d.ts +4 -4
- package/dist/api/collection.js +4 -4
- package/dist/api/functions.d.ts +43 -3
- package/dist/api/functions.js +74 -15
- package/dist/context.d.ts +7 -0
- package/dist/docs/API_SUMMARY.md +175 -39
- package/dist/docs/ai.md +1805 -1779
- package/dist/docs/server-functions.md +678 -629
- package/dist/http.d.ts +11 -0
- package/dist/http.js +18 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/openapi.yaml +252 -0
- package/dist/types/ai.d.ts +145 -1
- package/dist/types/collection.d.ts +9 -2
- package/dist/utils/paths.js +1 -1
- package/docs/API_SUMMARY.md +175 -39
- package/docs/ai.md +1805 -1779
- package/docs/server-functions.md +678 -629
- package/openapi.yaml +252 -0
- package/package.json +1 -1
|
@@ -1,629 +1,678 @@
|
|
|
1
|
-
# Server functions ("edge functions")
|
|
2
|
-
|
|
3
|
-
> **SmartLinks SDK 2.x** (current `latest`). Part of the installable-app platform
|
|
4
|
-
> (author → register → install → run → test). Install `@proveanything/smartlinks@^2`.
|
|
5
|
-
|
|
6
|
-
A **server function** is arbitrary server-side JavaScript your app deploys directly into
|
|
7
|
-
SmartLinks. It runs on the SmartLinks servers — with access to the full SDK, to your app's
|
|
8
|
-
secrets, and to outbound network — so you can do things a browser app can't: validate and
|
|
9
|
-
write on the server, call third-party systems with credentials the client never sees, react
|
|
10
|
-
to events, and run scheduled work.
|
|
11
|
-
|
|
12
|
-
Every server function has the **same shape**, no matter how it's triggered or where it runs:
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
export async function myFunction(ctx, event) {
|
|
16
|
-
// ctx — a SmartLinks SDK pre-scoped to your declared authority, plus secrets, caller, fetch, log
|
|
17
|
-
// event — the trigger payload (the HTTP body, the event, or the cron tick)
|
|
18
|
-
return { ok: true } // returned to the caller (http) or recorded as the run result (event/cron)
|
|
19
|
-
}
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
You never receive a raw API key or a superuser client. The platform builds `ctx` fresh for
|
|
23
|
-
each invocation and **pre-scopes every handle to exactly what your function declared** — this
|
|
24
|
-
is how a function stays safe even when it runs with elevated authority.
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## Declaring a function
|
|
29
|
-
|
|
30
|
-
Functions are declared in your `app.manifest.json` under `functions`, and the handlers ship
|
|
31
|
-
in a bundle alongside your widgets/containers:
|
|
32
|
-
|
|
33
|
-
```jsonc
|
|
34
|
-
{
|
|
35
|
-
"functions": {
|
|
36
|
-
"files": { "js": { "umd": "dist/functions.umd.js" } },
|
|
37
|
-
"definitions": [
|
|
38
|
-
{
|
|
39
|
-
"name": "submitCompetitionEntry",
|
|
40
|
-
"description": "Validate a competition entry and record it server-side.",
|
|
41
|
-
"trigger": { "type": "http", "methods": ["POST"] },
|
|
42
|
-
"visibility": "public", // WHO may call it
|
|
43
|
-
"authority": "collection", // WHOSE authority it runs as
|
|
44
|
-
"elevated": true, // required ack for public + collection
|
|
45
|
-
"capabilities": ["sl:records:write", "network:api.recaptcha.net"],
|
|
46
|
-
"apiVersion": "2026-09"
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"name": "onProofCreated",
|
|
50
|
-
"trigger": { "type": "event", "eventTypes": ["proof.created"] },
|
|
51
|
-
"capabilities": ["sl:records:write", "secrets:crm-key", "network"]
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
"name": "nightlyReconcile",
|
|
55
|
-
"trigger": { "type": "cron", "schedule": "0 2 * * *" },
|
|
56
|
-
"capabilities": ["sl:products:read", "sl:records:write"]
|
|
57
|
-
}
|
|
58
|
-
]
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Each definition maps to an exported handler of the same `name` (override with `handler`).
|
|
64
|
-
|
|
65
|
-
## Building the functions bundle
|
|
66
|
-
|
|
67
|
-
The handlers ship as a **self-contained UMD** — one file, all dependencies compiled in, no
|
|
68
|
-
`require`/`import` of platform or Node modules (see [Runtime](#runtime--what-your-function-can-use)).
|
|
69
|
-
Your functions entry just **named-exports** each handler:
|
|
70
|
-
|
|
71
|
-
```js
|
|
72
|
-
// src/functions/index.js — the functions entry
|
|
73
|
-
export async function submitCompetitionEntry(ctx, event) { /* … */ }
|
|
74
|
-
export async function onProofCreated(ctx, event) { /* … */ }
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Build it to `dist/functions.umd.js` with a Vite lib build — **UMD format, nothing externalised**
|
|
78
|
-
(so it's self-contained), and don't wipe the widgets you built into the same `dist/`:
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
// vite.functions.config.ts
|
|
82
|
-
import { defineConfig } from 'vite'
|
|
83
|
-
export default defineConfig({
|
|
84
|
-
build: {
|
|
85
|
-
lib: { entry: 'src/functions/index.js', formats: ['umd'], name: 'functions',
|
|
86
|
-
fileName: () => 'functions.umd.js' },
|
|
87
|
-
outDir: 'dist',
|
|
88
|
-
emptyOutDir: false, // co-exist with the widgets/containers build
|
|
89
|
-
rollupOptions: { external: [] } // bundle everything — no Node/platform externals
|
|
90
|
-
},
|
|
91
|
-
})
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
```jsonc
|
|
95
|
-
// package.json — build widgets/containers as usual, then the functions bundle
|
|
96
|
-
"scripts": {
|
|
97
|
-
"build": "vite build && vite build --config vite.functions.config.ts"
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
That emits `dist/functions.umd.js` (matching `functions.files.js.umd`), whose named exports the
|
|
102
|
-
platform pairs with your manifest `definitions`. Then **deploy** it — for dev,
|
|
103
|
-
`smartlinks-publish` uploads `dist/` and registers in one step (see
|
|
104
|
-
[Deploying & registering](deploying-apps.md)).
|
|
105
|
-
|
|
106
|
-
---
|
|
107
|
-
|
|
108
|
-
## The two security questions every function answers
|
|
109
|
-
|
|
110
|
-
Server-side code needs two questions answered up front. SmartLinks makes both **explicit and
|
|
111
|
-
declarative** — you state them in the manifest, and the platform enforces them. This is the
|
|
112
|
-
same split every extensible platform lands on (Salesforce `with/without sharing`, Shopify
|
|
113
|
-
online/offline tokens, Lambda's invoke-policy vs execution-role).
|
|
114
|
-
|
|
115
|
-
### 1. `visibility` — who is allowed to call it? *(http only)*
|
|
116
|
-
|
|
117
|
-
| Value | Meaning |
|
|
118
|
-
|---|---|
|
|
119
|
-
| `admin` *(default)* | Callable only from an authenticated admin surface. |
|
|
120
|
-
| `public` | Publicly callable — no authenticated user required. |
|
|
121
|
-
|
|
122
|
-
### 2. `authority` — whose authority does it run as?
|
|
123
|
-
|
|
124
|
-
This decides what `ctx.sl` can do.
|
|
125
|
-
|
|
126
|
-
| Value | `ctx.sl` is scoped to | Use when |
|
|
127
|
-
|---|---|---|
|
|
128
|
-
| `caller` *(default)* | The **invoking user** (their session/JWT). Can only do what that user could. | Admin actions that should respect the user's own permissions and be attributed to them. |
|
|
129
|
-
| `collection` | A **collection-admin principal for _this_ collection only** — never a global superuser. | A privileged server-side action a public/anonymous caller can't be trusted to do directly. |
|
|
130
|
-
|
|
131
|
-
`authority` **defaults to `caller`** — secure by default. `event`- and `cron`-triggered
|
|
132
|
-
functions have no external caller, so they always run as `collection`.
|
|
133
|
-
|
|
134
|
-
### The combinations
|
|
135
|
-
|
|
136
|
-
- **`http` + `admin` + `caller`** — a delegated admin function; runs as the signed-in admin.
|
|
137
|
-
- **`http` + `public` + `caller`** — runs as the anonymous/public user (limited). The safe public default.
|
|
138
|
-
- **`http` + `public` + `collection`** — publicly callable, runs with collection authority.
|
|
139
|
-
The powerful one (e.g. "submit competition entry" needs to validate then write a record no
|
|
140
|
-
anonymous user may write directly). **Your responsibility:** validate the input and prevent
|
|
141
|
-
abuse — see below.
|
|
142
|
-
- **`event` / `cron`** — background work; runs as `collection`.
|
|
143
|
-
|
|
144
|
-
> ### ⚠️ Public + collection authority is the sharp edge
|
|
145
|
-
> A `public` + `collection` function is reachable by anyone and runs with elevated authority.
|
|
146
|
-
> The platform shrinks the blast radius for you — the authority is capped to **this one
|
|
147
|
-
> collection**, and further capped by your declared **capabilities** (a competition-entry
|
|
148
|
-
> function that declares only `sl:records:write` cannot delete products or read other
|
|
149
|
-
> secrets, even though it's "elevated"). But **validating the request is still your job**:
|
|
150
|
-
> check the payload, rate-limit using `ctx.caller`, guard against replay. Treat the function
|
|
151
|
-
> body as a trust boundary.
|
|
152
|
-
>
|
|
153
|
-
> Because it's the sharp edge, a `public` + `collection` function must **explicitly opt in**
|
|
154
|
-
> with `elevated: true` in its declaration — a conscious acknowledgment that you're exposing
|
|
155
|
-
> collection authority to public callers. Without it, install/validation fails.
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
|
-
## Capabilities — least privilege, declared and capped
|
|
160
|
-
|
|
161
|
-
`capabilities` is the allow-list of what your function may reach. It is surfaced at install
|
|
162
|
-
time for consent and **enforced at runtime** — including for `collection`-authority functions.
|
|
163
|
-
Declare the minimum you need.
|
|
164
|
-
|
|
165
|
-
| Capability | Grants |
|
|
166
|
-
|---|---|
|
|
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
|
-
| `network` | `ctx.fetch` to any host. |
|
|
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. |
|
|
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`.
|
|
174
|
-
|
|
175
|
-
---
|
|
176
|
-
|
|
177
|
-
## The `ctx` object
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
interface ServerFunctionContext {
|
|
181
|
-
collectionId: string
|
|
182
|
-
appId: string
|
|
183
|
-
|
|
184
|
-
/** SmartLinks SDK, pre-scoped to your declared `authority`. Capabilities cap what it may do.
|
|
185
|
-
* Key resources: `sl.appRecords` (per-app records), `sl.appData` (general/app-wide config +
|
|
186
|
-
* data — URLs, flags, counters, arrays), `sl.products`, `sl.attestations`. */
|
|
187
|
-
sl: SmartLinks
|
|
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. */
|
|
191
|
-
secrets: { get(ref: string): Promise<string | null>; app(ref: string): Promise<string | null> }
|
|
192
|
-
|
|
193
|
-
/** Who invoked this function. */
|
|
194
|
-
caller: {
|
|
195
|
-
userId: string | null // null for anonymous/public/system calls
|
|
196
|
-
anonymous: boolean
|
|
197
|
-
origin?: string | null // http
|
|
198
|
-
ip?: string | null // http
|
|
199
|
-
via: 'http' | 'event' | 'cron'
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Outbound HTTP — present only if you declared `network` (host-scoped if `network:<host>`). */
|
|
203
|
-
fetch: typeof fetch
|
|
204
|
-
|
|
205
|
-
/** Structured logging — returned inline on the admin surface and recorded as an
|
|
206
|
-
* execution activity event (owner Errors & Activity console). See "Seeing your
|
|
207
|
-
* function's runs and logs" below. */
|
|
208
|
-
log: (message: string, data?: Record<string, any>) => void
|
|
209
|
-
}
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
**Use `ctx.sl` for anything SmartLinks** — it is the same SDK surface you use client-side,
|
|
213
|
-
already authenticated as your declared authority and scoped to the collection. Do **not** try
|
|
214
|
-
to construct your own SDK client or carry your own key; that's what `ctx.sl` is for, and it's
|
|
215
|
-
the only way authority stays correct.
|
|
216
|
-
|
|
217
|
-
### Calling a function from your app (client side)
|
|
218
|
-
|
|
219
|
-
From a page/UI, invoke an `http`-trigger function with the SDK — no manual fetch, no URL building:
|
|
220
|
-
|
|
221
|
-
```ts
|
|
222
|
-
// public function (runs on the caller's authority — signed-in owner, else public):
|
|
223
|
-
const res = await SL.functions.call(collectionId, 'increment', { /* becomes event.body */ })
|
|
224
|
-
// admin surface (needs an admin session):
|
|
225
|
-
const res = await SL.functions.callAdmin(collectionId, 'recomputeTotals', { … })
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
`res` is whatever your handler returns. See the `edge-function-test` example for a full page +
|
|
229
|
-
function + manifest.
|
|
230
|
-
|
|
231
|
-
#### Functions are ALWAYS app-scoped
|
|
232
|
-
|
|
233
|
-
A function belongs to an app, so the canonical route carries the appId:
|
|
234
|
-
`POST /collection/:collectionId/app/:appId/functions/:name`. Two different installed apps can each
|
|
235
|
-
ship a function of the same name without clashing.
|
|
236
|
-
|
|
237
|
-
The SDK fills the appId in for you. Initialize with your app's id once, then call by bare name —
|
|
238
|
-
the SDK routes it app-scoped:
|
|
239
|
-
|
|
240
|
-
```ts
|
|
241
|
-
SL.initializeApi({ baseURL, appId: 'my-counter-app' }) // usually done by the host/bootstrap
|
|
242
|
-
await SL.functions.call(collectionId, 'pressCounter') // → /collection/:c/app/my-counter-app/functions/pressCounter
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
To call a *different* app's function, pass the appId explicitly:
|
|
246
|
-
|
|
247
|
-
```ts
|
|
248
|
-
await SL.functions.call(collectionId, 'pressCounter', {}, { appId: 'some-other-app' })
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
**
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
`
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
- **
|
|
431
|
-
`
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1
|
+
# Server functions ("edge functions")
|
|
2
|
+
|
|
3
|
+
> **SmartLinks SDK 2.x** (current `latest`). Part of the installable-app platform
|
|
4
|
+
> (author → register → install → run → test). Install `@proveanything/smartlinks@^2`.
|
|
5
|
+
|
|
6
|
+
A **server function** is arbitrary server-side JavaScript your app deploys directly into
|
|
7
|
+
SmartLinks. It runs on the SmartLinks servers — with access to the full SDK, to your app's
|
|
8
|
+
secrets, and to outbound network — so you can do things a browser app can't: validate and
|
|
9
|
+
write on the server, call third-party systems with credentials the client never sees, react
|
|
10
|
+
to events, and run scheduled work.
|
|
11
|
+
|
|
12
|
+
Every server function has the **same shape**, no matter how it's triggered or where it runs:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
export async function myFunction(ctx, event) {
|
|
16
|
+
// ctx — a SmartLinks SDK pre-scoped to your declared authority, plus secrets, caller, fetch, log
|
|
17
|
+
// event — the trigger payload (the HTTP body, the event, or the cron tick)
|
|
18
|
+
return { ok: true } // returned to the caller (http) or recorded as the run result (event/cron)
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
You never receive a raw API key or a superuser client. The platform builds `ctx` fresh for
|
|
23
|
+
each invocation and **pre-scopes every handle to exactly what your function declared** — this
|
|
24
|
+
is how a function stays safe even when it runs with elevated authority.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Declaring a function
|
|
29
|
+
|
|
30
|
+
Functions are declared in your `app.manifest.json` under `functions`, and the handlers ship
|
|
31
|
+
in a bundle alongside your widgets/containers:
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
{
|
|
35
|
+
"functions": {
|
|
36
|
+
"files": { "js": { "umd": "dist/functions.umd.js" } },
|
|
37
|
+
"definitions": [
|
|
38
|
+
{
|
|
39
|
+
"name": "submitCompetitionEntry",
|
|
40
|
+
"description": "Validate a competition entry and record it server-side.",
|
|
41
|
+
"trigger": { "type": "http", "methods": ["POST"] },
|
|
42
|
+
"visibility": "public", // WHO may call it
|
|
43
|
+
"authority": "collection", // WHOSE authority it runs as
|
|
44
|
+
"elevated": true, // required ack for public + collection
|
|
45
|
+
"capabilities": ["sl:records:write", "network:api.recaptcha.net"],
|
|
46
|
+
"apiVersion": "2026-09"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "onProofCreated",
|
|
50
|
+
"trigger": { "type": "event", "eventTypes": ["proof.created"] },
|
|
51
|
+
"capabilities": ["sl:records:write", "secrets:crm-key", "network"]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "nightlyReconcile",
|
|
55
|
+
"trigger": { "type": "cron", "schedule": "0 2 * * *" },
|
|
56
|
+
"capabilities": ["sl:products:read", "sl:records:write"]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each definition maps to an exported handler of the same `name` (override with `handler`).
|
|
64
|
+
|
|
65
|
+
## Building the functions bundle
|
|
66
|
+
|
|
67
|
+
The handlers ship as a **self-contained UMD** — one file, all dependencies compiled in, no
|
|
68
|
+
`require`/`import` of platform or Node modules (see [Runtime](#runtime--what-your-function-can-use)).
|
|
69
|
+
Your functions entry just **named-exports** each handler:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// src/functions/index.js — the functions entry
|
|
73
|
+
export async function submitCompetitionEntry(ctx, event) { /* … */ }
|
|
74
|
+
export async function onProofCreated(ctx, event) { /* … */ }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Build it to `dist/functions.umd.js` with a Vite lib build — **UMD format, nothing externalised**
|
|
78
|
+
(so it's self-contained), and don't wipe the widgets you built into the same `dist/`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// vite.functions.config.ts
|
|
82
|
+
import { defineConfig } from 'vite'
|
|
83
|
+
export default defineConfig({
|
|
84
|
+
build: {
|
|
85
|
+
lib: { entry: 'src/functions/index.js', formats: ['umd'], name: 'functions',
|
|
86
|
+
fileName: () => 'functions.umd.js' },
|
|
87
|
+
outDir: 'dist',
|
|
88
|
+
emptyOutDir: false, // co-exist with the widgets/containers build
|
|
89
|
+
rollupOptions: { external: [] } // bundle everything — no Node/platform externals
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```jsonc
|
|
95
|
+
// package.json — build widgets/containers as usual, then the functions bundle
|
|
96
|
+
"scripts": {
|
|
97
|
+
"build": "vite build && vite build --config vite.functions.config.ts"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
That emits `dist/functions.umd.js` (matching `functions.files.js.umd`), whose named exports the
|
|
102
|
+
platform pairs with your manifest `definitions`. Then **deploy** it — for dev,
|
|
103
|
+
`smartlinks-publish` uploads `dist/` and registers in one step (see
|
|
104
|
+
[Deploying & registering](deploying-apps.md)).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## The two security questions every function answers
|
|
109
|
+
|
|
110
|
+
Server-side code needs two questions answered up front. SmartLinks makes both **explicit and
|
|
111
|
+
declarative** — you state them in the manifest, and the platform enforces them. This is the
|
|
112
|
+
same split every extensible platform lands on (Salesforce `with/without sharing`, Shopify
|
|
113
|
+
online/offline tokens, Lambda's invoke-policy vs execution-role).
|
|
114
|
+
|
|
115
|
+
### 1. `visibility` — who is allowed to call it? *(http only)*
|
|
116
|
+
|
|
117
|
+
| Value | Meaning |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `admin` *(default)* | Callable only from an authenticated admin surface. |
|
|
120
|
+
| `public` | Publicly callable — no authenticated user required. |
|
|
121
|
+
|
|
122
|
+
### 2. `authority` — whose authority does it run as?
|
|
123
|
+
|
|
124
|
+
This decides what `ctx.sl` can do.
|
|
125
|
+
|
|
126
|
+
| Value | `ctx.sl` is scoped to | Use when |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `caller` *(default)* | The **invoking user** (their session/JWT). Can only do what that user could. | Admin actions that should respect the user's own permissions and be attributed to them. |
|
|
129
|
+
| `collection` | A **collection-admin principal for _this_ collection only** — never a global superuser. | A privileged server-side action a public/anonymous caller can't be trusted to do directly. |
|
|
130
|
+
|
|
131
|
+
`authority` **defaults to `caller`** — secure by default. `event`- and `cron`-triggered
|
|
132
|
+
functions have no external caller, so they always run as `collection`.
|
|
133
|
+
|
|
134
|
+
### The combinations
|
|
135
|
+
|
|
136
|
+
- **`http` + `admin` + `caller`** — a delegated admin function; runs as the signed-in admin.
|
|
137
|
+
- **`http` + `public` + `caller`** — runs as the anonymous/public user (limited). The safe public default.
|
|
138
|
+
- **`http` + `public` + `collection`** — publicly callable, runs with collection authority.
|
|
139
|
+
The powerful one (e.g. "submit competition entry" needs to validate then write a record no
|
|
140
|
+
anonymous user may write directly). **Your responsibility:** validate the input and prevent
|
|
141
|
+
abuse — see below.
|
|
142
|
+
- **`event` / `cron`** — background work; runs as `collection`.
|
|
143
|
+
|
|
144
|
+
> ### ⚠️ Public + collection authority is the sharp edge
|
|
145
|
+
> A `public` + `collection` function is reachable by anyone and runs with elevated authority.
|
|
146
|
+
> The platform shrinks the blast radius for you — the authority is capped to **this one
|
|
147
|
+
> collection**, and further capped by your declared **capabilities** (a competition-entry
|
|
148
|
+
> function that declares only `sl:records:write` cannot delete products or read other
|
|
149
|
+
> secrets, even though it's "elevated"). But **validating the request is still your job**:
|
|
150
|
+
> check the payload, rate-limit using `ctx.caller`, guard against replay. Treat the function
|
|
151
|
+
> body as a trust boundary.
|
|
152
|
+
>
|
|
153
|
+
> Because it's the sharp edge, a `public` + `collection` function must **explicitly opt in**
|
|
154
|
+
> with `elevated: true` in its declaration — a conscious acknowledgment that you're exposing
|
|
155
|
+
> collection authority to public callers. Without it, install/validation fails.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Capabilities — least privilege, declared and capped
|
|
160
|
+
|
|
161
|
+
`capabilities` is the allow-list of what your function may reach. It is surfaced at install
|
|
162
|
+
time for consent and **enforced at runtime** — including for `collection`-authority functions.
|
|
163
|
+
Declare the minimum you need.
|
|
164
|
+
|
|
165
|
+
| Capability | Grants |
|
|
166
|
+
|---|---|
|
|
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
|
+
| `network` | `ctx.fetch` to any host. |
|
|
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. |
|
|
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`.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## The `ctx` object
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
interface ServerFunctionContext {
|
|
181
|
+
collectionId: string
|
|
182
|
+
appId: string
|
|
183
|
+
|
|
184
|
+
/** SmartLinks SDK, pre-scoped to your declared `authority`. Capabilities cap what it may do.
|
|
185
|
+
* Key resources: `sl.appRecords` (per-app records), `sl.appData` (general/app-wide config +
|
|
186
|
+
* data — URLs, flags, counters, arrays), `sl.products`, `sl.attestations`. */
|
|
187
|
+
sl: SmartLinks
|
|
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. */
|
|
191
|
+
secrets: { get(ref: string): Promise<string | null>; app(ref: string): Promise<string | null> }
|
|
192
|
+
|
|
193
|
+
/** Who invoked this function. */
|
|
194
|
+
caller: {
|
|
195
|
+
userId: string | null // null for anonymous/public/system calls
|
|
196
|
+
anonymous: boolean
|
|
197
|
+
origin?: string | null // http
|
|
198
|
+
ip?: string | null // http
|
|
199
|
+
via: 'http' | 'event' | 'cron'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Outbound HTTP — present only if you declared `network` (host-scoped if `network:<host>`). */
|
|
203
|
+
fetch: typeof fetch
|
|
204
|
+
|
|
205
|
+
/** Structured logging — returned inline on the admin surface and recorded as an
|
|
206
|
+
* execution activity event (owner Errors & Activity console). See "Seeing your
|
|
207
|
+
* function's runs and logs" below. */
|
|
208
|
+
log: (message: string, data?: Record<string, any>) => void
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Use `ctx.sl` for anything SmartLinks** — it is the same SDK surface you use client-side,
|
|
213
|
+
already authenticated as your declared authority and scoped to the collection. Do **not** try
|
|
214
|
+
to construct your own SDK client or carry your own key; that's what `ctx.sl` is for, and it's
|
|
215
|
+
the only way authority stays correct.
|
|
216
|
+
|
|
217
|
+
### Calling a function from your app (client side)
|
|
218
|
+
|
|
219
|
+
From a page/UI, invoke an `http`-trigger function with the SDK — no manual fetch, no URL building:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// public function (runs on the caller's authority — signed-in owner, else public):
|
|
223
|
+
const res = await SL.functions.call(collectionId, 'increment', { /* becomes event.body */ })
|
|
224
|
+
// admin surface (needs an admin session):
|
|
225
|
+
const res = await SL.functions.callAdmin(collectionId, 'recomputeTotals', { … })
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`res` is whatever your handler returns. See the `edge-function-test` example for a full page +
|
|
229
|
+
function + manifest.
|
|
230
|
+
|
|
231
|
+
#### Functions are ALWAYS app-scoped
|
|
232
|
+
|
|
233
|
+
A function belongs to an app, so the canonical route carries the appId:
|
|
234
|
+
`POST /collection/:collectionId/app/:appId/functions/:name`. Two different installed apps can each
|
|
235
|
+
ship a function of the same name without clashing.
|
|
236
|
+
|
|
237
|
+
The SDK fills the appId in for you. Initialize with your app's id once, then call by bare name —
|
|
238
|
+
the SDK routes it app-scoped:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
SL.initializeApi({ baseURL, appId: 'my-counter-app' }) // usually done by the host/bootstrap
|
|
242
|
+
await SL.functions.call(collectionId, 'pressCounter') // → /collection/:c/app/my-counter-app/functions/pressCounter
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
To call a *different* app's function, pass the appId explicitly:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
await SL.functions.call(collectionId, 'pressCounter', {}, { appId: 'some-other-app' })
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Where it runs.** A function runs on a collection when the app is **installed** there (enabled in
|
|
252
|
+
the collection's apps); or the app is **restricted** to a list of collections (what publishing from
|
|
253
|
+
Forge sets for every developer) and this collection is on it, e.g. your own sandbox; or the app is
|
|
254
|
+
**public**, meaning a registered app with no restriction list, which only platform admins can
|
|
255
|
+
publish. Anything else gets `404 APP_NOT_INSTALLED`: a function can run with the collection's own
|
|
256
|
+
authority and secrets, so a developer's app can't reach a collection that hasn't taken it on.
|
|
257
|
+
|
|
258
|
+
**Who may run server code at all.** Until functions move to an isolated runner, only TRUSTED apps
|
|
259
|
+
execute: public (unrestricted) apps, or apps a platform admin has marked `functionsTrusted: true`.
|
|
260
|
+
Developer apps — restricted to their own collections, which includes every app published from
|
|
261
|
+
self-serve Forge — get `403 FUNCTIONS_NOT_ENABLED` until they're trusted. A platform admin can also
|
|
262
|
+
switch any app's functions off with `functionsTrusted: false`.
|
|
263
|
+
|
|
264
|
+
**Which release.** With no channel, the server runs the release the collection has **installed** (stable for a
|
|
265
|
+
public app that isn't installed) — what production wants, so app code just calls `SL.functions.call(collectionId, name)`. To run
|
|
266
|
+
another release, the channel goes **in the path**: `/app/:appId/<dev|alpha|beta|stable>/functions/:name`.
|
|
267
|
+
The SDK adds it for you when the host tells the app which channel it's running as — Forge's preview
|
|
268
|
+
of a Test build passes `appChannel=dev` in the app's context, so the same code calls the dev build
|
|
269
|
+
there and the installed build in production. You can also set it yourself:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
SL.initializeApi({ baseURL, appId: 'my-counter-app', appChannel: 'dev' }) // whole app
|
|
273
|
+
await SL.functions.call(collectionId, 'pressCounter', {}, { channel: 'beta' }) // one call
|
|
274
|
+
await SL.functions.call(collectionId, 'pressCounter', {}, { channel: null }) // force the installed release
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Public address on the collection's own site (webhooks, integrations).** Every collection has a
|
|
278
|
+
site host — `<name>.smartlinks.host` once a name is claimed, else `c-<shortId>.smartlinks.host` —
|
|
279
|
+
returned as `collection.siteHost`. App functions are reachable there:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
https://<siteHost>/_fn/<appId>[/<channel>]/<function>[/<sub-path…>]
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const col = await SL.collection.get(collectionId)
|
|
287
|
+
const webhookUrl = SL.functions.siteUrl(col, 'stripeWebhook', { appId: 'my-shop' })
|
|
288
|
+
// → https://acme.smartlinks.host/_fn/my-shop/stripeWebhook (give this to Stripe)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Built for integrations: **every HTTP method** the function declares in `trigger.methods` (default
|
|
292
|
+
POST only), **any content type** with the exact bytes in `event.rawBody` (verify signatures with
|
|
293
|
+
`crypto.subtle`), **sub-paths** after the name when the function declares `trigger.path` (`"/*"`, or a
|
|
294
|
+
pattern like `"/orders/:id"` → `event.params.id`), and the function's own `Response` — status, headers,
|
|
295
|
+
CORS — goes back unchanged (the platform adds no CORS headers there; declare `OPTIONS` and answer
|
|
296
|
+
preflights yourself if browsers call you cross-origin). Bodies up to 6 MB.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
// manifest: { name: 'orders', trigger: { type: 'http', methods: ['GET', 'PUT'], path: '/orders/:id' }, visibility: 'public' }
|
|
300
|
+
export async function orders(ctx, event) {
|
|
301
|
+
if (event.method === 'GET') return ctx.sl.appRecords.get(event.params.id)
|
|
302
|
+
// PUT: verify the sender first — e.g. an HMAC over event.rawBody with a secret
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Security: the platform never hands a caller's SmartLinks credential to function code (if it signed
|
|
307
|
+
the caller in from `Authorization`, that header is removed and you get `ctx.caller`); your OWN
|
|
308
|
+
`Authorization` scheme for webhooks passes through. `siteUrl` only includes a channel you ask for —
|
|
309
|
+
point production webhooks at the bare address and test ones at `/dev/`.
|
|
310
|
+
|
|
311
|
+
The channel is never a query parameter: the function owns its query string (`?channel=sms` reaches
|
|
312
|
+
your handler untouched), and a configured URL — a webhook, a third-party callback — can only ever
|
|
313
|
+
hit the channel it names. Point production webhooks at the bare path and test ones at `/dev/`.
|
|
314
|
+
|
|
315
|
+
If no appId is available (not set on init, none passed), the call falls back to the **deprecated
|
|
316
|
+
flat path** `/collection/:c/functions/:name`, which searches only the collection's **enabled** apps,
|
|
317
|
+
resolves by bare name, and **rejects with `409 AMBIGUOUS_FUNCTION`** when more than one defines that
|
|
318
|
+
name (a first-party builtin still wins). Always prefer an appId.
|
|
319
|
+
|
|
320
|
+
> There is no such thing as an app-less function. First-party builtins (e.g. the `functions.ping`
|
|
321
|
+
> diagnostic) belong to the reserved `smartlinks` app. A "global" function is just an app enabled on
|
|
322
|
+
> the `global` collection and called at `/collection/global/app/:appId/functions/:name` — same two
|
|
323
|
+
> facets (collection + app), with `global` as the sentinel collection.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Exposing a function to the AI agent
|
|
328
|
+
|
|
329
|
+
An `http` function can be offered to the AI agent as a **callable tool**, alongside the built-in
|
|
330
|
+
tools. The model calls it, the server runs it, and the result is fed back into the loop. Opt in from
|
|
331
|
+
the manifest with an `agent` block (full reference: [agent-tools.md](agent-tools.md)):
|
|
332
|
+
|
|
333
|
+
```jsonc
|
|
334
|
+
{
|
|
335
|
+
"name": "getLoyaltyBalance",
|
|
336
|
+
"trigger": { "type": "http" },
|
|
337
|
+
"visibility": "admin",
|
|
338
|
+
"authority": "caller",
|
|
339
|
+
"capabilities": ["sl:records:read"],
|
|
340
|
+
"agent": {
|
|
341
|
+
"tool": true,
|
|
342
|
+
"title": "Loyalty balance",
|
|
343
|
+
"description": "Look up a member's current loyalty points balance.",
|
|
344
|
+
"input": {
|
|
345
|
+
"type": "object",
|
|
346
|
+
"properties": { "memberId": { "type": "string" } },
|
|
347
|
+
"required": ["memberId"]
|
|
348
|
+
},
|
|
349
|
+
"approval": "auto" // 'require' = human confirms before each call
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The agent becomes **just another caller surface** — your function's `visibility`, `authority`, and
|
|
355
|
+
`capabilities` are enforced exactly as on the http route. Nothing new is granted. The agent's tool
|
|
356
|
+
arguments arrive as the function's request `body`, and whatever you return becomes the tool result the
|
|
357
|
+
model sees. `approval: "require"` tools are held back from the autonomous server-side loop until the
|
|
358
|
+
human-approval UX ships.
|
|
359
|
+
|
|
360
|
+
Include your app's functions in an agent run:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
// Agentic Responses:
|
|
364
|
+
await SL.ai.chat.responses.create(collectionId, {
|
|
365
|
+
model: 'balanced', input: 'What is member 42's balance?',
|
|
366
|
+
server_tools: true, // built-in tools
|
|
367
|
+
app_functions: { appId: 'my-loyalty-app' }, // + this app's ai.tool functions
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Or the one-shot agent loop:
|
|
371
|
+
await SL.ai.agent.run(collectionId, {
|
|
372
|
+
input: '…', appFunctions: { appId: 'my-loyalty-app' },
|
|
373
|
+
})
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
On the **consumer surface**, a `visibility: "public"` function reaches a public assistant via the
|
|
377
|
+
public agent loop — the caller runs as the signed-in consumer (`'owner'`, send the authKit bearer) or
|
|
378
|
+
anonymous (`'public'`):
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
await SL.ai.publicClient.agentRun(collectionId, {
|
|
382
|
+
input: '…',
|
|
383
|
+
appFunctions: { appId: 'my-app' }, // this app's public agent tools
|
|
384
|
+
server_tools: ['web.search'], // built-ins are an explicit allowlist on the public surface
|
|
385
|
+
})
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`channel` (default `'stable'`, pass `'dev'` to test a dev build) and `only: string[]` narrow which
|
|
389
|
+
functions are exposed. Only `http` functions with `agent.tool: true` are eligible; `event`/`cron`
|
|
390
|
+
functions never are. A built-in tool of the same name wins the clash.
|
|
391
|
+
|
|
392
|
+
### One unified toolbelt
|
|
393
|
+
|
|
394
|
+
Rather than juggling `server_tools` + `app_functions`, declare everything in one `toolbelt` — built-in
|
|
395
|
+
tools, one **or several** apps' functions, and (reserved, staged) front-end client tools:
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
await SL.ai.chat.responses.create(collectionId, {
|
|
399
|
+
input: '…',
|
|
400
|
+
toolbelt: {
|
|
401
|
+
builtins: ['web.search', 'document.read'], // true = all, [names] = subset, omit = none
|
|
402
|
+
appFunctions: [{ appId: 'loyalty' }, { appId: 'catalog' }], // several apps at once
|
|
403
|
+
// clientTools: [ … ] // reserved — the client-tool bridge is staged
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The same `toolbelt` works on `ai.agent.run` and `ai.publicClient.agentRun` (on the public surface
|
|
409
|
+
`builtins` is an explicit allowlist — `true` is treated as none). Precedence on a name clash: a
|
|
410
|
+
built-in wins, then earlier `appFunctions` sources win over later ones.
|
|
411
|
+
|
|
412
|
+
> Directly (no model): every function is also callable deterministically — `SL.functions.call` /
|
|
413
|
+
> `callAdmin` (above), the same way the built-in tools are callable via `SL.ai.tools.run`.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Runtime — what your function can use
|
|
418
|
+
|
|
419
|
+
Your function runs in a **web-standard sandbox** (think Cloudflare Workers / Deno), **not
|
|
420
|
+
Node**. Concretely it targets the **WinterTC Minimum Common Web Platform API** (WinterTC is the
|
|
421
|
+
Ecma International technical committee for this, formerly WinterCG) — the same surface those
|
|
422
|
+
runtimes guarantee — so what you can rely on is portable, and the future isolated runner will
|
|
423
|
+
host the same bundle unchanged.
|
|
424
|
+
|
|
425
|
+
**Available globals** (no import needed):
|
|
426
|
+
- **Data / encoding:** `JSON`, `URL`, `URLSearchParams`, `TextEncoder` / `TextDecoder`,
|
|
427
|
+
`atob` / `btoa`, `Buffer`, `structuredClone`.
|
|
428
|
+
- **Crypto:** `crypto` (Web Crypto) — `crypto.subtle` for hashing / HMAC / encrypt / sign /
|
|
429
|
+
verify, `crypto.getRandomValues`, `crypto.randomUUID`.
|
|
430
|
+
- **HTTP:** `fetch`, `Headers`, `Request`, `Response`, `FormData`, `Blob`, `File`,
|
|
431
|
+
`AbortController` / `AbortSignal` (use one for **request timeouts**).
|
|
432
|
+
- **Streams & timing:** `ReadableStream` / `WritableStream` / `TransformStream`,
|
|
433
|
+
`CompressionStream` / `DecompressionStream` (gzip/deflate for third-party payloads), the timer
|
|
434
|
+
functions, `queueMicrotask`, `performance`, `console`.
|
|
435
|
+
- **Runtime identity:** `navigator.userAgent` reports the runtime key (currently
|
|
436
|
+
`"SmartLinks-Functions"`) — use it if a portable dependency needs to feature-detect the host.
|
|
437
|
+
It stays stable when functions move to the isolated runner.
|
|
438
|
+
|
|
439
|
+
**Not available:** `require` / `import` of platform or Node modules, `process`, `fs`, and the
|
|
440
|
+
Node built-ins (`node:crypto`, `node:http`, …). There is no ambient database, key, or network
|
|
441
|
+
handle — everything the platform gives you comes through **`ctx`**.
|
|
442
|
+
|
|
443
|
+
**Dependencies:** bundle them. Your build must produce a **self-contained** UMD (deps compiled
|
|
444
|
+
in), and those deps must be **edge-compatible** — pure JS / Web APIs. A library that reaches for
|
|
445
|
+
Node built-ins (e.g. `axios`'s Node adapter, anything using `node:crypto`) will fail to load.
|
|
446
|
+
Prefer the platform primitives above over a dependency; when you do need one, pick edge-safe:
|
|
447
|
+
|
|
448
|
+
| Need | Native? | Recommended (bundle, edge-safe) |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| JSON | **native** — `JSON.parse/stringify` | — |
|
|
451
|
+
| XML parse / build | no | `fast-xml-parser` |
|
|
452
|
+
| CSV | no | `papaparse` |
|
|
453
|
+
| Schema validation of inputs | no | `zod` |
|
|
454
|
+
| JWT (sign/verify for a third-party API) | Web Crypto can, verbosely | `jose` |
|
|
455
|
+
| Hash / HMAC / encrypt | **native** — `crypto.subtle` | — |
|
|
456
|
+
| PDF **text** extraction (cheap first-pass, no AI) | no | `unpdf` — a serverless/WASM pdf.js build with no Node deps; `import { extractText } from 'unpdf'`. Text only. |
|
|
457
|
+
|
|
458
|
+
> **PDFs in a server function:** for a cheap text-layer check use `unpdf` (edge-safe, WASM). For
|
|
459
|
+
> anything heavier — rendering pages, vision extraction, **barcode/QR decode**, or prepress/spot-colour
|
|
460
|
+
> inspection, **OCR of small print** — call the platform tools `pdf.render` / `pdf.extract` /
|
|
461
|
+
> `pdf.decodeBarcodes` / `pdf.inspectGraphics` / `image.ocr` via `ai.tools.run`; those run in the full
|
|
462
|
+
> platform runtime, not the sandbox.
|
|
463
|
+
|
|
464
|
+
**The common actions, and how to do each:**
|
|
465
|
+
|
|
466
|
+
| You want to… | Use | Requires |
|
|
467
|
+
|---|---|---|
|
|
468
|
+
| Call a third-party API (GET/POST) | `ctx.fetch(url, init)` — the standard Fetch API | capability `network` or `network:<host>` |
|
|
469
|
+
| Read a secret (API key, signing key) | `await ctx.secrets.get('<ref>')` | capability `secrets:<ref>` |
|
|
470
|
+
| Hash / HMAC-sign / verify / encrypt | `crypto.subtle` (Web Crypto) | — |
|
|
471
|
+
| Random id / bytes | `crypto.randomUUID()` / `crypto.getRandomValues()` | — |
|
|
472
|
+
| Read or write SmartLinks data | `ctx.sl.*` (records, products, attestations, …) | the matching `sl:<res>:<read\|write>` |
|
|
473
|
+
| Read/write your app's own data (config, URLs, counters, arrays) | `ctx.sl.appData.get()` / `ctx.sl.appData.set({…})` — collection-scoped by default; `ctx.sl.appData.global.*` (or `{ scope:'global' }`, or declare `dataScope:'global'`) for the app-wide bucket. It's your OWN namespace, so **any** authority may read/write it | `sl:data:read` / `sl:data:write` |
|
|
474
|
+
| Read ANOTHER app's data (as the caller may see it) | `ctx.sl.app('other-app').data.get()` — caller-authority, this collection, public-filtered, read-only | `sl:data:read` |
|
|
475
|
+
| Guarantee a UNIQUE claim (pool of numbers, one-per-user, idempotency key) | `ctx.sl.appRecords.create({ ref: 'ball:57' })` — the DB unique index rejects a duplicate `ref` (catch = "already taken") | `sl:records:write` |
|
|
476
|
+
|
|
477
|
+
Notes:
|
|
478
|
+
- **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
|
|
479
|
+
process, then act on the core* — is: check the input, then call `ctx.sl.appRecords.create(…)`,
|
|
480
|
+
`ctx.sl.products.update(…)`, etc. `ctx.sl` is already authenticated as your declared authority
|
|
481
|
+
and capped by your capabilities, so you never construct a SmartLinks API URL or carry a key.
|
|
482
|
+
Use `fetch`/`ctx.fetch` for *third-party* servers; use `ctx.sl` for SmartLinks itself.
|
|
483
|
+
- **There is one `fetch`, and it is capability-gated.** Whether you call the global `fetch` or
|
|
484
|
+
`ctx.fetch`, the behaviour is identical: the call **throws** unless your declared `network`
|
|
485
|
+
(or `network:<host>`) capability covers the target host — the same way Deno's `fetch` throws
|
|
486
|
+
without `--allow-net`. There is no ungated escape hatch. Declare the hosts you need.
|
|
487
|
+
- **Secrets are read-only at runtime.** You fetch a secret you declared; you do **not** set or
|
|
488
|
+
rotate secrets from a function — that's an admin/deploy-time operation on the platform.
|
|
489
|
+
- **Signing a webhook / verifying a signature** is `crypto.subtle` with an HMAC key imported
|
|
490
|
+
from a secret — no Node `crypto` needed.
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## Worked example — a public competition entry
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
// dist/functions — handler for the manifest definition above
|
|
498
|
+
export async function submitCompetitionEntry(ctx, event) {
|
|
499
|
+
const { name, email, answer, captchaToken } = event.body || {}
|
|
500
|
+
|
|
501
|
+
// 1. Validate — this is YOUR job on a public function.
|
|
502
|
+
if (!email || !answer) return { ok: false, error: 'missing_fields' }
|
|
503
|
+
|
|
504
|
+
// 2. Use a declared secret + declared host to verify a captcha.
|
|
505
|
+
const secret = await ctx.secrets.get('recaptcha-secret')
|
|
506
|
+
const verify = await ctx.fetch('https://api.recaptcha.net/verify', {
|
|
507
|
+
method: 'POST',
|
|
508
|
+
body: new URLSearchParams({ secret, response: captchaToken }),
|
|
509
|
+
}).then(r => r.json())
|
|
510
|
+
if (!verify.success) return { ok: false, error: 'captcha_failed' }
|
|
511
|
+
|
|
512
|
+
// 3. Write with collection authority — something an anonymous caller can't do directly.
|
|
513
|
+
const entry = await ctx.sl.appRecords.create({
|
|
514
|
+
recordType: 'competition-entry',
|
|
515
|
+
data: { name, email, answer, ip: ctx.caller.ip, submittedAt: new Date().toISOString() },
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
ctx.log('entry recorded', { entryId: entry.id })
|
|
519
|
+
return { ok: true, entryId: entry.id }
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Declared as `public` + `collection` + `['sl:records:write', 'secrets:recaptcha-secret',
|
|
524
|
+
'network:api.recaptcha.net']`, this function is publicly callable, verifies the request
|
|
525
|
+
itself, and writes a record no anonymous user could write — but it cannot touch products,
|
|
526
|
+
other secrets, or any other collection.
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## Invoking an http function
|
|
531
|
+
|
|
532
|
+
An `http` function is called by POSTing to the app-scoped functions endpoint on the
|
|
533
|
+
surface that matches its `visibility`:
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
POST /admin/collection/:collectionId/app/:appId/functions/:name # visibility: admin (collection-admin auth)
|
|
537
|
+
POST /public/collection/:collectionId/app/:appId/functions/:name # visibility: public (auth optional)
|
|
538
|
+
GET /{admin|public}/collection/:collectionId/app/:appId/functions # list this app's functions on the surface
|
|
539
|
+
POST /{admin|public}/collection/:collectionId/app/:appId/dev/functions/:name # a specific channel's release
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Add a channel segment to run a specific release: `/app/:appId/dev/functions/:name` (also `alpha`,
|
|
543
|
+
`beta`, `stable`; anything else is a 404). Without one, the collection's installed release runs.
|
|
544
|
+
The app must be installed on the collection, on the app's restricted list, or a public app (see
|
|
545
|
+
"Where it runs"); otherwise `404 APP_NOT_INSTALLED`.
|
|
546
|
+
|
|
547
|
+
The bare-name form (`/collection/:c/functions/:name`, no `/app/:appId`) is a **deprecated alias**:
|
|
548
|
+
it searches only the collection's **enabled** apps, resolves by name, lets a first-party builtin
|
|
549
|
+
win, and returns `409 AMBIGUOUS_FUNCTION` when two enabled apps define the same name. Prefer the
|
|
550
|
+
app-scoped route (the SDK emits it automatically once an appId is set — see "Calling a function
|
|
551
|
+
from your app").
|
|
552
|
+
|
|
553
|
+
The handler receives the request as `event`: `event.body` (parsed JSON), `event.query`,
|
|
554
|
+
`event.headers`, `event.rawBody` (raw bytes, for XML/form/other inputs), and `event.contentType`.
|
|
555
|
+
A function only runs on its own surface — calling an `admin` function on the public endpoint is a
|
|
556
|
+
`403`. `GET` on either endpoint lists the functions callable on that surface.
|
|
557
|
+
|
|
558
|
+
**Response contract — your return IS the response (no envelope):**
|
|
559
|
+
- Return a **plain value** → it becomes the JSON body, HTTP `200`. (`SL.functions.call()` gives you
|
|
560
|
+
that value directly — `res.value`, not `res.result.value`.)
|
|
561
|
+
- Return a web-standard **`Response`** → passed through verbatim: your status, headers, content-type
|
|
562
|
+
and body (XML, CSV, text, binary, redirect, custom status). `Response` is a runtime global.
|
|
563
|
+
```js
|
|
564
|
+
return new Response(toXml(data), { status: 200, headers: { "content-type": "application/xml" } })
|
|
565
|
+
```
|
|
566
|
+
- **Throw** → HTTP `500` `{ error: "FUNCTION_ERROR", message, code }`. For *expected* errors (400/404/
|
|
567
|
+
409…), return a `Response` with your own status + body.
|
|
568
|
+
|
|
569
|
+
One rule: **plain object ⇒ 200; want any other status/headers/content-type ⇒ return a `Response`.**
|
|
570
|
+
Timing comes back in an `X-SL-Function-Duration-Ms` header (admin surface); your `ctx.log` lines land
|
|
571
|
+
in the collection's Errors & Activity feed — the body stays purely your output.
|
|
572
|
+
|
|
573
|
+
The admin surface is collection-admin gated, so an admin function's `caller` authority runs
|
|
574
|
+
at admin level, attributed to the signed-in admin. The public surface resolves auth if a
|
|
575
|
+
token is present (→ `owner`) and treats its absence as anonymous (→ `public`); a
|
|
576
|
+
`collection`-authority function runs elevated regardless.
|
|
577
|
+
|
|
578
|
+
### Before it will resolve
|
|
579
|
+
|
|
580
|
+
An http call returns `404 FUNCTION_NOT_FOUND` unless **both** of these are true (this is the
|
|
581
|
+
most common first-run surprise):
|
|
582
|
+
|
|
583
|
+
1. **The app's release is registered on the channel** the collection follows (see
|
|
584
|
+
[deploying-apps.md](deploying-apps.md)) — that's what publishes the function bundle into
|
|
585
|
+
the registry the runtime loads from.
|
|
586
|
+
2. **The app is enabled on the collection** (`appConfig.apps[]`, on that same channel — see
|
|
587
|
+
[appConfig.md](appConfig.md)).
|
|
588
|
+
|
|
589
|
+
So the end-to-end path is: *write → register the release → enable on a collection → call.*
|
|
590
|
+
|
|
591
|
+
### Seeing your function's runs and logs
|
|
592
|
+
|
|
593
|
+
Every invocation is recorded as an **execution** activity event, carrying your `ctx.log`
|
|
594
|
+
lines. Where to look, easiest first:
|
|
595
|
+
|
|
596
|
+
- **Admin-surface response** — timing comes back in the `X-SL-Function-Duration-Ms` header (logs are in Errors & Activity, not the body).
|
|
597
|
+
- **Deployed test mode** — see below; real run, isolated logging.
|
|
598
|
+
- **Owner console** — the collection's **Advanced → Errors & Activity → events** tab,
|
|
599
|
+
filtered to **source = execution**: each run shows as `function <name> ok` (or an error),
|
|
600
|
+
and the detail carries your `ctx.log` output. (Telemetry is streamed, so allow a few
|
|
601
|
+
seconds.)
|
|
602
|
+
|
|
603
|
+
## Testing & preview
|
|
604
|
+
|
|
605
|
+
You don't have to deploy to find out whether a function works. There are three levels of
|
|
606
|
+
fidelity — use them in order.
|
|
607
|
+
|
|
608
|
+
### 1. Local harness (fast, offline)
|
|
609
|
+
|
|
610
|
+
`@proveanything/smartlinks/testing` builds a `ctx` that **enforces the declared capability
|
|
611
|
+
envelope**, so a function fails locally the same way it would in production — the common
|
|
612
|
+
"I forgot to declare `sl:records:write`" bug is caught before you deploy, not after.
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
import { createFunctionTestContext } from '@proveanything/smartlinks/testing'
|
|
616
|
+
import manifest from '../public/app.manifest.json'
|
|
617
|
+
import { submitCompetitionEntry } from '../src/functions'
|
|
618
|
+
|
|
619
|
+
const def = manifest.functions.definitions.find(d => d.name === 'submitCompetitionEntry')
|
|
620
|
+
|
|
621
|
+
const ctx = createFunctionTestContext({
|
|
622
|
+
def, // capabilities enforced come from the manifest itself
|
|
623
|
+
caller: { userId: 'tester' },
|
|
624
|
+
secrets: { 'recaptcha-secret': 'test-value' }, // fixtures — real secrets are server-only
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
const res = await submitCompetitionEntry(ctx, { method: 'POST', body: { email: 'a@b.com', answer: '42' } })
|
|
628
|
+
// ctx.sl.appRecords.create(...) throws CapabilityError unless `def` declares sl:records:write
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Pass the **`def`** (not a hand-typed capability list) so "tested" can't drift from
|
|
632
|
+
"declared". By default `ctx.sl` methods return a stub result (pure unit test — no network);
|
|
633
|
+
inject `sl` to delegate to your live SDK for real reads/writes:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
const ctx = createFunctionTestContext({
|
|
637
|
+
def,
|
|
638
|
+
sl: { appRecords: { create: (fields) => mySdk.app.records.create(fields) } },
|
|
639
|
+
})
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### 2. Deployed test mode (high fidelity, safe)
|
|
643
|
+
|
|
644
|
+
Register to the `dev` channel and invoke on the real server — real secrets, real data —
|
|
645
|
+
without a live run *(coming next)*: a test invocation is forced to `caller` authority,
|
|
646
|
+
side-effecting writes are dry-run, and the traffic is logged separately from live metrics.
|
|
647
|
+
|
|
648
|
+
### 3. Live
|
|
649
|
+
|
|
650
|
+
Point a real collection at the channel and invoke for real.
|
|
651
|
+
|
|
652
|
+
### What differs across the three
|
|
653
|
+
|
|
654
|
+
| | Capabilities | `ctx.sl` | Secrets | Authority | Writes |
|
|
655
|
+
|---|---|---|---|---|---|
|
|
656
|
+
| **Local harness** | Enforced (from `def`) | Stub, or your injected SDK | Fixtures you pass | Informational | Whatever your impl does |
|
|
657
|
+
| **Deployed test** | Enforced | Real (test-scoped) | Real | Forced to `caller` | Dry-run |
|
|
658
|
+
| **Live** | Enforced | Real | Real | As declared | Real |
|
|
659
|
+
|
|
660
|
+
### Recommended CI pattern
|
|
661
|
+
|
|
662
|
+
1. **Unit** — run each handler through `createFunctionTestContext` (no network); assert
|
|
663
|
+
behaviour *and* that capabilities are sufficient (an under-declared capability throws).
|
|
664
|
+
2. **Post-deploy smoke** — after registering to `dev`, hit each function once in test mode.
|
|
665
|
+
|
|
666
|
+
## Where functions run (and why it doesn't change how you write them)
|
|
667
|
+
|
|
668
|
+
SmartLinks runs first-party (trusted) functions **in-process** and untrusted third-party
|
|
669
|
+
functions in an **isolated runner**. The difference is enforcement, not authoring:
|
|
670
|
+
|
|
671
|
+
- **In-process** trusts the author; `ctx` is built directly.
|
|
672
|
+
- **Isolated runner** enforces the `authority` boundary and `capabilities` at the container
|
|
673
|
+
edge — `ctx.sl` is a proxy over the declared authority, `ctx.fetch` is filtered to the
|
|
674
|
+
declared hosts, and there is no ambient filesystem, network, or environment.
|
|
675
|
+
|
|
676
|
+
Because the **contract is identical**, a function you write today runs unchanged if it later
|
|
677
|
+
moves lanes. Write to the `ctx` contract and declare your capabilities honestly, and the
|
|
678
|
+
platform takes care of the rest.
|