@proveanything/smartlinks 1.17.6 → 2.0.0-alpha.2
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/docs/API_SUMMARY.md +3 -1
- package/dist/docs/deploying-apps.md +244 -0
- package/dist/docs/server-functions.md +72 -0
- package/dist/openapi.yaml +2 -0
- package/dist/testing/index.d.ts +64 -0
- package/dist/testing/index.js +145 -0
- package/dist/types/appManifest.d.ts +7 -0
- package/docs/API_SUMMARY.md +3 -1
- package/docs/deploying-apps.md +244 -0
- package/docs/server-functions.md +72 -0
- package/openapi.yaml +2 -0
- package/package.json +7 -2
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0-alpha.2 | Generated: 2026-09-14T14:13:56.533Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -22,6 +22,7 @@ For detailed guides on specific features:
|
|
|
22
22
|
- **[App Configuration Files](app-manifest.md)** - `app.manifest.json` and `app.admin.json` reference — bundles, components, setup questions, import schemas, tunable fields, and metrics
|
|
23
23
|
- **[Executor Model](executor.md)** - Programmatic JS bundles for AI-driven setup, server-side SEO metadata generation, and LLM content for AI crawlers
|
|
24
24
|
- **[Server Functions](server-functions.md)** - App-authored server-side "edge functions" (`async (ctx, event) => result`): http/event/cron triggers, the visibility/authority/capabilities security model, and the pre-scoped `ctx` (authority-scoped SDK, capability-gated secrets + fetch)
|
|
25
|
+
- **[Deploying & Registering an App](deploying-apps.md)** - Publish → build → register: the app CDN layout (smartlinks.app), dev/beta/prod channels, channel-scoped deploy keys, the `POST /apps/:appId/releases` registration endpoint + validation, and how to wire it into your build so a bad manifest fails the deploy
|
|
25
26
|
- **[Realtime](realtime.md)** - Real-time data updates and WebSocket connections
|
|
26
27
|
- **[iframe Responder](iframe-responder.md)** - iframe integration and cross-origin communication
|
|
27
28
|
- **[iframe Streaming Parent Changes](iframe-streaming-parent-changes.md)** - Parent-side changes required to support AI streaming in iframe proxy mode
|
|
@@ -1722,6 +1723,7 @@ interface AppFunctionDef {
|
|
|
1722
1723
|
trigger: AppFunctionTrigger;
|
|
1723
1724
|
visibility?: AppFunctionVisibility;
|
|
1724
1725
|
authority?: AppFunctionAuthority;
|
|
1726
|
+
elevated?: boolean;
|
|
1725
1727
|
capabilities?: string[];
|
|
1726
1728
|
apiVersion?: string;
|
|
1727
1729
|
handler?: string;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Deploying & registering an app
|
|
2
|
+
|
|
3
|
+
> **Preview — SmartLinks SDK 2.0.0-alpha.** Part of the installable-app platform being built
|
|
4
|
+
> toward 2.0.0 stable. These APIs may change before then. Published under the npm `next` tag;
|
|
5
|
+
> `latest` remains 1.x.
|
|
6
|
+
|
|
7
|
+
A SmartLinks app is a bundle (widgets, containers, and — new — [server functions](server-functions.md))
|
|
8
|
+
described by an `app.manifest.json`. Deploying an app has two halves:
|
|
9
|
+
|
|
10
|
+
1. **Publish the bundles** to the SmartLinks app CDN.
|
|
11
|
+
2. **Register the release** — tell the SmartLinks backend a new version exists, hand it the
|
|
12
|
+
manifest, and let it validate everything. Registration is what makes the app (and its
|
|
13
|
+
server functions) known, installable, and testable.
|
|
14
|
+
|
|
15
|
+
This guide covers registration and how to wire it into your build.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Two "wheres": the API host vs the bundle CDN
|
|
20
|
+
|
|
21
|
+
Deploying touches two different locations — keep them distinct:
|
|
22
|
+
|
|
23
|
+
1. **The install endpoint (API host)** — *where you register*. On the **main SaaS this is
|
|
24
|
+
`https://smartlinks.app`** (the API lives under `/api/v1` — the same base your app already
|
|
25
|
+
passes to `initializeApi`). A client's isolated (VPC) environment has its **own** host; you
|
|
26
|
+
install into it by calling that host. This is the value that varies per deployment.
|
|
27
|
+
2. **The bundle CDN** — *where your built files are actually served from*, which you pass at
|
|
28
|
+
registration as **`bundleBaseUrl`**. **This depends on the channel:**
|
|
29
|
+
- **`dev` (Lovable):** your files are served by **Lovable at your published app URL** — that
|
|
30
|
+
URL is your dev `bundleBaseUrl`. Lovable *is* the CDN for dev; nothing is uploaded to
|
|
31
|
+
SmartLinks.
|
|
32
|
+
- **`prod`:** files are published to the SmartLinks app CDN at
|
|
33
|
+
`https://smartlinks.app/apps/{appId}/{version}/…` by your prod deploy step; that base is
|
|
34
|
+
your prod `bundleBaseUrl`.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
https://smartlinks.app/apps/{appId}/{version}/app.manifest.json # prod layout
|
|
38
|
+
https://smartlinks.app/apps/{appId}/{version}/functions.umd.js
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Always pass `bundleBaseUrl` explicitly so it keeps working if a location changes.
|
|
42
|
+
|
|
43
|
+
> **Who publishes the bundles?** The platform does **not** fetch or mirror them for you —
|
|
44
|
+
> `bundleBaseUrl` must already serve the files at registration time. For `dev` that's Lovable
|
|
45
|
+
> (on Publish); for `prod` that's your own CDN deploy step. Registration records *where* the
|
|
46
|
+
> bundles are + the validated manifest; it does not host anything.
|
|
47
|
+
|
|
48
|
+
## Environments & the app registry
|
|
49
|
+
|
|
50
|
+
Historically an app was **stateless everywhere**: every environment fetched the same CDN
|
|
51
|
+
manifest on the fly, so apps were agnostic to shards, clients, and VPCs. Registration changes
|
|
52
|
+
that — an app is now **installed into an environment's registry**, which is **global to that
|
|
53
|
+
environment (not per-collection / not per-shard)**.
|
|
54
|
+
|
|
55
|
+
An **environment** is one SmartLinks deployment: the main SaaS, or a client's isolated (VPC)
|
|
56
|
+
instance. Each owns its own app registry, so:
|
|
57
|
+
|
|
58
|
+
- The **install endpoint's host is the environment** — `POST https://<that-env's-api>/api/v1/apps/…`.
|
|
59
|
+
Installing into a client's VPC means calling *their* API with *their* deploy key.
|
|
60
|
+
- **Rolling out to several environments = the same call, once per target** (different API host
|
|
61
|
+
+ key each). The registration script below takes the API base as a parameter for exactly this.
|
|
62
|
+
|
|
63
|
+
> Enabling an app on a specific **collection** (and consenting to its capabilities) is a
|
|
64
|
+
> separate, per-collection step layered on top of the environment's registry.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Channels
|
|
69
|
+
|
|
70
|
+
A release is registered on one **channel**, matching how you deploy:
|
|
71
|
+
|
|
72
|
+
| Channel | Deployed from | Typical use |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `dev` | **Lovable "Publish"** | Live development / testing |
|
|
75
|
+
| `beta` | your pipeline | Staging / preview |
|
|
76
|
+
| `prod` | your **Cloud Build** toolset (from git) | Production |
|
|
77
|
+
|
|
78
|
+
A collection chooses which channel it follows, so you can point a test collection at `dev`
|
|
79
|
+
and exercise a build before it reaches `prod`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Deploy keys
|
|
84
|
+
|
|
85
|
+
Registration is authenticated by a **deploy key** — not a user login — scoped to the channels
|
|
86
|
+
it may write. Security comes from the *scope*: a leaked key can only do what its scope allows.
|
|
87
|
+
|
|
88
|
+
| Key | Where to keep it | May write | If it leaks |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| **Dev key** | a **Lovable workspace Build Secret** (below) | `dev` channel, **any app in the environment** | someone can register/overwrite `dev` releases — which are served only to test collections — but **never** `beta`/`prod` |
|
|
91
|
+
| **Prod master key** | your CI/Cloud Build secret store **only** | all channels | full control — so keep it out of app source entirely |
|
|
92
|
+
| **Per-app key** *(future)* | provisioned per app | one specific app | scoped to that one app |
|
|
93
|
+
|
|
94
|
+
**Keep the dev key in Lovable Build Secrets — do not commit it.** Lovable exposes
|
|
95
|
+
**workspace-level Build Secrets** shared across every project in the workspace: set the dev key
|
|
96
|
+
there **once** and every microapp inherits it, nothing is committed, and there's a single value
|
|
97
|
+
to rotate. (An earlier draft of this guide said to put the key in app source — don't; use Build
|
|
98
|
+
Secrets.)
|
|
99
|
+
|
|
100
|
+
**Minting (current state, 2.0.0-alpha):** the `dev` and `prod` keys are **shared secrets
|
|
101
|
+
configured on the SmartLinks backend** — one `dev` key and one `prod` key covering all
|
|
102
|
+
first-party apps. There is **no self-service minting UI yet**: ask the platform owner for the
|
|
103
|
+
`dev` key and put it in your workspace Build Secret. Per-app keys, self-service minting, and
|
|
104
|
+
rotation are planned; rotating the shared `dev` key just means updating the one Build Secret
|
|
105
|
+
(registered releases are unaffected — the key authorizes *writes*, it isn't stored on releases).
|
|
106
|
+
|
|
107
|
+
Present the key in the `x-smartlinks-deploy-key` header.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## The registration endpoint
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
POST https://smartlinks.app/api/v1/apps/{appId}/releases # main SaaS; a VPC env uses its own host
|
|
115
|
+
x-smartlinks-deploy-key: <your deploy key>
|
|
116
|
+
Content-Type: application/json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{
|
|
121
|
+
"channel": "dev", // dev | beta | prod
|
|
122
|
+
"version": "1.2.3", // from your manifest meta.version
|
|
123
|
+
"build": { "at": "2026-09-14T10:00:00Z", "gitHash": "abc1234", "builder": "lovable" },
|
|
124
|
+
"manifest": { /* your full app.manifest.json, including the functions block */ },
|
|
125
|
+
"bundleBaseUrl": "https://smartlinks.app/apps/currys/1.2.3"
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Responses
|
|
130
|
+
|
|
131
|
+
| Status | Meaning |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `200` | Registered. Body: `{ ok: true, appId, channel, version, functions: [names], registeredAt }` |
|
|
134
|
+
| `422` | Validation failed — **the build should fail**. Body: `{ ok: false, errors: [...] }` |
|
|
135
|
+
| `401` | Missing/invalid deploy key |
|
|
136
|
+
| `403` | Key not permitted for that channel (e.g. a dev key targeting `prod`) — `CHANNEL_FORBIDDEN` |
|
|
137
|
+
|
|
138
|
+
Each validation error is `{ code, message, path }`, e.g.:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{ "ok": false, "errors": [
|
|
142
|
+
{ "code": "AUTHORITY_INVALID", "message": "invalid authority \"root\"", "path": "functions.definitions[0].authority" },
|
|
143
|
+
{ "code": "CAPABILITY_INVALID", "message": "invalid capability \"bogus\"", "path": "functions.definitions[0].capabilities[0]" }
|
|
144
|
+
]}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Re-registering & version collisions
|
|
148
|
+
|
|
149
|
+
Registration is an **upsert keyed by (app, channel)** — the newest registration becomes that
|
|
150
|
+
channel's live release. Registering again **overwrites** it (last write wins); there is **no**
|
|
151
|
+
version-collision error even if `meta.version` is unchanged. So re-publishing to `dev` without
|
|
152
|
+
bumping the version is a normal, intentional overwrite — which is what you want on `dev`. (A
|
|
153
|
+
stricter `prod` policy that rejects a duplicate `version` may come later; it isn't enforced
|
|
154
|
+
today.)
|
|
155
|
+
|
|
156
|
+
### What gets validated
|
|
157
|
+
|
|
158
|
+
- `manifest.meta.appId` must match the `{appId}` in the URL.
|
|
159
|
+
- The **`functions` block** is validated with the *same* rules the runtime enforces
|
|
160
|
+
(names, triggers, `visibility`/`authority`, the capability grammar, duplicate names) — so
|
|
161
|
+
a malformed server function is caught **at deploy time**, not at runtime. See
|
|
162
|
+
[server-functions.md](server-functions.md).
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Wiring it into your build
|
|
167
|
+
|
|
168
|
+
Registration is the **last step of your build** — after bundles are built and hashed. Run a
|
|
169
|
+
small script that reads your built manifest and POSTs it, and **exits non-zero on failure**
|
|
170
|
+
so a bad install fails the publish.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
// scripts/register-release.mjs — run as the build's postbuild step
|
|
174
|
+
import { readFileSync } from 'node:fs'
|
|
175
|
+
import { execSync } from 'node:child_process'
|
|
176
|
+
|
|
177
|
+
const API = process.env.SMARTLINKS_API || 'https://smartlinks.app' // a VPC env: set to its host
|
|
178
|
+
const KEY = process.env.SMARTLINKS_DEPLOY_KEY // Lovable Build Secret (dev) / CI secret (prod)
|
|
179
|
+
const CHANNEL = process.env.SMARTLINKS_CHANNEL // 'dev' | 'beta' | 'prod'; UNSET ⇒ don't register
|
|
180
|
+
|
|
181
|
+
// --- Gate: only register when this build is meant to ---
|
|
182
|
+
// A preview / live-edit build (no channel) skips quietly so it never fails. A build that
|
|
183
|
+
// declares a channel but has no key is a hard error IF it's prod; dev skips quietly.
|
|
184
|
+
if (!CHANNEL) {
|
|
185
|
+
console.log('ℹ︎ SmartLinks: SMARTLINKS_CHANNEL unset — skipping release registration (preview build).')
|
|
186
|
+
process.exit(0)
|
|
187
|
+
}
|
|
188
|
+
if (!KEY) {
|
|
189
|
+
if (CHANNEL === 'prod') { console.error('❌ prod build but SMARTLINKS_DEPLOY_KEY is missing'); process.exit(1) }
|
|
190
|
+
console.log(`ℹ︎ SmartLinks: no deploy key for "${CHANNEL}" — skipping registration.`)
|
|
191
|
+
process.exit(0)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const manifest = JSON.parse(readFileSync('dist/app.manifest.json', 'utf8'))
|
|
195
|
+
const appId = manifest.meta.appId
|
|
196
|
+
const version = manifest.meta.version
|
|
197
|
+
const gitHash = (() => { try { return execSync('git rev-parse --short HEAD').toString().trim() } catch { return null } })()
|
|
198
|
+
// dev: your published Lovable URL; prod: the SmartLinks CDN base. Set SMARTLINKS_BUNDLE_BASE_URL.
|
|
199
|
+
const bundleBaseUrl = process.env.SMARTLINKS_BUNDLE_BASE_URL || `https://smartlinks.app/apps/${appId}/${version}`
|
|
200
|
+
|
|
201
|
+
const res = await fetch(`${API}/api/v1/apps/${appId}/releases`, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
channel: CHANNEL, version, manifest, bundleBaseUrl,
|
|
206
|
+
build: { at: new Date().toISOString(), gitHash, builder: CHANNEL === 'dev' ? 'lovable' : 'ci' },
|
|
207
|
+
}),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const body = await res.json().catch(() => ({}))
|
|
211
|
+
if (!res.ok || !body.ok) {
|
|
212
|
+
console.error(`❌ SmartLinks registration failed (${res.status}):`)
|
|
213
|
+
for (const e of body.errors || []) console.error(` • ${e.path}: ${e.message}`)
|
|
214
|
+
process.exit(1) // fail the build
|
|
215
|
+
}
|
|
216
|
+
console.log(`✅ Registered ${appId}@${version} on "${CHANNEL}" — functions: ${(body.functions || []).join(', ') || 'none'}`)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Wire it after your bundle build/hash step:
|
|
220
|
+
|
|
221
|
+
```jsonc
|
|
222
|
+
// package.json
|
|
223
|
+
"scripts": {
|
|
224
|
+
"build": "vite build && … && node scripts/hash-bundles.mjs",
|
|
225
|
+
"postbuild": "node scripts/register-release.mjs"
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Registration is gated by **`SMARTLINKS_CHANNEL`**, so the three Lovable build types behave correctly:
|
|
230
|
+
|
|
231
|
+
| Build | `SMARTLINKS_CHANNEL` | Result |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| **Preview / live-edit** | unset | **skips quietly** — never registers, never fails |
|
|
234
|
+
| **Dev (Publish)** | `dev` | registers to `dev` with the workspace Build-Secret key + your Lovable `SMARTLINKS_BUNDLE_BASE_URL` |
|
|
235
|
+
| **Prod (CI)** | `prod` | registers to `prod` with the prod key; a missing key **hard-fails** |
|
|
236
|
+
|
|
237
|
+
The gate is `SMARTLINKS_CHANNEL`, so **set it only where you want a release** — i.e. on the
|
|
238
|
+
Publish/CI build, not on preview. If your host exposes a publish-only signal (an env var it sets
|
|
239
|
+
only on Publish), key `SMARTLINKS_CHANNEL` off that; otherwise set it in the Publish build's env
|
|
240
|
+
and leave it unset for preview. That one variable is the difference between "this build ships a
|
|
241
|
+
release" and "this build is just a preview."
|
|
242
|
+
|
|
243
|
+
That's it: a real Publish validates + registers your app (a broken manifest or function stops
|
|
244
|
+
the deploy with an actionable error), while preview builds stay quiet.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Server functions ("edge functions")
|
|
2
2
|
|
|
3
|
+
> **Preview — SmartLinks SDK 2.0.0-alpha.** Part of the installable-app platform being built
|
|
4
|
+
> toward 2.0.0 stable (author → register → install → run → test). These APIs may change before
|
|
5
|
+
> then. Published under the npm `next` tag; `latest` remains 1.x.
|
|
6
|
+
|
|
3
7
|
A **server function** is arbitrary server-side JavaScript your app deploys directly into
|
|
4
8
|
SmartLinks. It runs on the SmartLinks servers — with access to the full SDK, to your app's
|
|
5
9
|
secrets, and to outbound network — so you can do things a browser app can't: validate and
|
|
@@ -38,6 +42,7 @@ in a bundle alongside your widgets/containers:
|
|
|
38
42
|
"trigger": { "type": "http", "methods": ["POST"] },
|
|
39
43
|
"visibility": "public", // WHO may call it
|
|
40
44
|
"authority": "collection", // WHOSE authority it runs as
|
|
45
|
+
"elevated": true, // required ack for public + collection
|
|
41
46
|
"capabilities": ["sl:records:write", "network:api.recaptcha.net"],
|
|
42
47
|
"apiVersion": "2026-09"
|
|
43
48
|
},
|
|
@@ -104,6 +109,10 @@ functions have no external caller, so they always run as `collection`.
|
|
|
104
109
|
> secrets, even though it's "elevated"). But **validating the request is still your job**:
|
|
105
110
|
> check the payload, rate-limit using `ctx.caller`, guard against replay. Treat the function
|
|
106
111
|
> body as a trust boundary.
|
|
112
|
+
>
|
|
113
|
+
> Because it's the sharp edge, a `public` + `collection` function must **explicitly opt in**
|
|
114
|
+
> with `elevated: true` in its declaration — a conscious acknowledgment that you're exposing
|
|
115
|
+
> collection authority to public callers. Without it, install/validation fails.
|
|
107
116
|
|
|
108
117
|
---
|
|
109
118
|
|
|
@@ -219,6 +228,69 @@ at admin level, attributed to the signed-in admin. The public surface resolves a
|
|
|
219
228
|
token is present (→ `owner`) and treats its absence as anonymous (→ `public`); a
|
|
220
229
|
`collection`-authority function runs elevated regardless.
|
|
221
230
|
|
|
231
|
+
## Testing & preview
|
|
232
|
+
|
|
233
|
+
You don't have to deploy to find out whether a function works. There are three levels of
|
|
234
|
+
fidelity — use them in order.
|
|
235
|
+
|
|
236
|
+
### 1. Local harness (fast, offline)
|
|
237
|
+
|
|
238
|
+
`@proveanything/smartlinks/testing` builds a `ctx` that **enforces the declared capability
|
|
239
|
+
envelope**, so a function fails locally the same way it would in production — the common
|
|
240
|
+
"I forgot to declare `sl:records:write`" bug is caught before you deploy, not after.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { createFunctionTestContext } from '@proveanything/smartlinks/testing'
|
|
244
|
+
import manifest from '../public/app.manifest.json'
|
|
245
|
+
import { submitCompetitionEntry } from '../src/functions'
|
|
246
|
+
|
|
247
|
+
const def = manifest.functions.definitions.find(d => d.name === 'submitCompetitionEntry')
|
|
248
|
+
|
|
249
|
+
const ctx = createFunctionTestContext({
|
|
250
|
+
def, // capabilities enforced come from the manifest itself
|
|
251
|
+
caller: { userId: 'tester' },
|
|
252
|
+
secrets: { 'recaptcha-secret': 'test-value' }, // fixtures — real secrets are server-only
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const res = await submitCompetitionEntry(ctx, { method: 'POST', body: { email: 'a@b.com', answer: '42' } })
|
|
256
|
+
// ctx.sl.appRecords.create(...) throws CapabilityError unless `def` declares sl:records:write
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Pass the **`def`** (not a hand-typed capability list) so "tested" can't drift from
|
|
260
|
+
"declared". By default `ctx.sl` methods return a stub result (pure unit test — no network);
|
|
261
|
+
inject `sl` to delegate to your live SDK for real reads/writes:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const ctx = createFunctionTestContext({
|
|
265
|
+
def,
|
|
266
|
+
sl: { appRecords: { create: (fields) => mySdk.app.records.create(fields) } },
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### 2. Deployed test mode (high fidelity, safe)
|
|
271
|
+
|
|
272
|
+
Register to the `dev` channel and invoke on the real server — real secrets, real data —
|
|
273
|
+
without a live run *(coming next)*: a test invocation is forced to `caller` authority,
|
|
274
|
+
side-effecting writes are dry-run, and the traffic is logged separately from live metrics.
|
|
275
|
+
|
|
276
|
+
### 3. Live
|
|
277
|
+
|
|
278
|
+
Point a real collection at the channel and invoke for real.
|
|
279
|
+
|
|
280
|
+
### What differs across the three
|
|
281
|
+
|
|
282
|
+
| | Capabilities | `ctx.sl` | Secrets | Authority | Writes |
|
|
283
|
+
|---|---|---|---|---|---|
|
|
284
|
+
| **Local harness** | Enforced (from `def`) | Stub, or your injected SDK | Fixtures you pass | Informational | Whatever your impl does |
|
|
285
|
+
| **Deployed test** | Enforced | Real (test-scoped) | Real | Forced to `caller` | Dry-run |
|
|
286
|
+
| **Live** | Enforced | Real | Real | As declared | Real |
|
|
287
|
+
|
|
288
|
+
### Recommended CI pattern
|
|
289
|
+
|
|
290
|
+
1. **Unit** — run each handler through `createFunctionTestContext` (no network); assert
|
|
291
|
+
behaviour *and* that capabilities are sufficient (an under-declared capability throws).
|
|
292
|
+
2. **Post-deploy smoke** — after registering to `dev`, hit each function once in test mode.
|
|
293
|
+
|
|
222
294
|
## Where functions run (and why it doesn't change how you write them)
|
|
223
295
|
|
|
224
296
|
SmartLinks runs first-party (trusted) functions **in-process** and untrusted third-party
|
package/dist/openapi.yaml
CHANGED
|
@@ -17281,6 +17281,8 @@ components:
|
|
|
17281
17281
|
$ref: "#/components/schemas/AppFunctionVisibility"
|
|
17282
17282
|
authority:
|
|
17283
17283
|
$ref: "#/components/schemas/AppFunctionAuthority"
|
|
17284
|
+
elevated:
|
|
17285
|
+
type: boolean
|
|
17284
17286
|
capabilities:
|
|
17285
17287
|
type: array
|
|
17286
17288
|
items:
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AppFunctionDef, ServerFunctionContext } from '../types/appManifest';
|
|
2
|
+
export declare class CapabilityError extends Error {
|
|
3
|
+
capability: string;
|
|
4
|
+
code: string;
|
|
5
|
+
constructor(capability: string, message?: string);
|
|
6
|
+
}
|
|
7
|
+
/** The surface `ctx.sl` exposes — mirrors the server facade. Provide the methods your test needs. */
|
|
8
|
+
export interface TestSlImpl {
|
|
9
|
+
appRecords?: {
|
|
10
|
+
create?(fields: any): any;
|
|
11
|
+
update?(id: string, fields: any): any;
|
|
12
|
+
upsert?(fields: any): any;
|
|
13
|
+
delete?(id: string): any;
|
|
14
|
+
get?(id: string): any;
|
|
15
|
+
query?(params: any): any;
|
|
16
|
+
listTypes?(): any;
|
|
17
|
+
};
|
|
18
|
+
products?: {
|
|
19
|
+
get?(id: string, opts?: any): any;
|
|
20
|
+
query?(body?: any, opts?: any): any;
|
|
21
|
+
create?(data: any, opts?: any): any;
|
|
22
|
+
update?(id: string, data: any, opts?: any): any;
|
|
23
|
+
};
|
|
24
|
+
attestations?: {
|
|
25
|
+
create?(fields: any): any;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface TestCaller {
|
|
29
|
+
userId?: string | null;
|
|
30
|
+
anonymous?: boolean;
|
|
31
|
+
origin?: string | null;
|
|
32
|
+
ip?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface CreateFunctionTestContextOptions {
|
|
35
|
+
/** The manifest declaration under test — its `capabilities` are the enforced envelope. */
|
|
36
|
+
def: Pick<AppFunctionDef, 'capabilities' | 'trigger' | 'visibility' | 'authority'>;
|
|
37
|
+
collectionId?: string;
|
|
38
|
+
appId?: string;
|
|
39
|
+
caller?: TestCaller;
|
|
40
|
+
/** Fixture secrets, keyed by ref. Real sealed secrets are server-only and never available locally. */
|
|
41
|
+
secrets?: Record<string, string>;
|
|
42
|
+
/** Backing impl for ctx.sl. Omit for pure unit tests (methods return a stub echo). */
|
|
43
|
+
sl?: TestSlImpl;
|
|
44
|
+
/** Backing fetch (defaults to global fetch). Still gated by the `network` capability. */
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
export interface FunctionTestContext extends ServerFunctionContext {
|
|
48
|
+
/** Captured log lines (also written via ctx.log). */
|
|
49
|
+
logs: Array<{
|
|
50
|
+
at: string;
|
|
51
|
+
message: string;
|
|
52
|
+
data?: Record<string, any>;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build a capability-enforcing test ctx for a server function. Run your handler with it:
|
|
57
|
+
*
|
|
58
|
+
* const ctx = createFunctionTestContext({ def, caller: { userId: 'me' }, secrets: { k: 'v' } })
|
|
59
|
+
* const result = await myHandler(ctx, { method: 'POST', body: { … } })
|
|
60
|
+
*
|
|
61
|
+
* A ctx.sl / ctx.secrets / ctx.fetch call not covered by `def.capabilities` throws
|
|
62
|
+
* CapabilityError — exactly as it would in production.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createFunctionTestContext(opts: CreateFunctionTestContextOptions): FunctionTestContext;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/testing/index.ts
|
|
2
|
+
//
|
|
3
|
+
// Local test harness for SmartLinks server functions — importable as
|
|
4
|
+
// `@proveanything/smartlinks/testing`. It builds a `ctx` that matches the runtime
|
|
5
|
+
// contract AND enforces the declared capability envelope, so a function fails locally
|
|
6
|
+
// the same way it would after deploy — no "deploy and pray".
|
|
7
|
+
//
|
|
8
|
+
// Fidelity (documented in docs/server-functions.md "Testing & preview"):
|
|
9
|
+
// - Capabilities are enforced EXACTLY as declared in the manifest `def` — pass the def
|
|
10
|
+
// itself so "tested" can't drift from "declared".
|
|
11
|
+
// - `ctx.sl` delegates to an impl you inject: your live SDK for real reads/writes, or
|
|
12
|
+
// omit it for pure unit tests (methods return a stub result instead of hitting the
|
|
13
|
+
// network — control flow + capability enforcement still run).
|
|
14
|
+
// - `ctx.secrets` are FIXTURES you provide; real sealed secrets are server-only and
|
|
15
|
+
// never resolvable locally.
|
|
16
|
+
// - `ctx.fetch` is gated by the `network` capability just like production.
|
|
17
|
+
export class CapabilityError extends Error {
|
|
18
|
+
constructor(capability, message) {
|
|
19
|
+
super(message || `capability not granted: ${capability}`);
|
|
20
|
+
this.code = 'CAPABILITY_DENIED';
|
|
21
|
+
this.name = 'CapabilityError';
|
|
22
|
+
this.capability = capability;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// The SAME grammar the server enforces (prove server/services/functions/validate.js).
|
|
26
|
+
function parseCapabilities(list = []) {
|
|
27
|
+
const sl = new Set();
|
|
28
|
+
const secrets = new Set();
|
|
29
|
+
const networkHosts = new Set();
|
|
30
|
+
let networkAll = false;
|
|
31
|
+
for (const raw of list || []) {
|
|
32
|
+
const cap = String(raw || '').trim();
|
|
33
|
+
if (!cap)
|
|
34
|
+
continue;
|
|
35
|
+
if (cap === 'network') {
|
|
36
|
+
networkAll = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (cap.startsWith('network:')) {
|
|
40
|
+
networkHosts.add(cap.slice(8).toLowerCase());
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (cap.startsWith('secrets:')) {
|
|
44
|
+
secrets.add(cap.slice(8));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (cap.startsWith('sl:')) {
|
|
48
|
+
sl.add(cap.slice(3));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { sl, secrets, networkAll, networkHosts };
|
|
53
|
+
}
|
|
54
|
+
function allowsSl(p, resource, op) {
|
|
55
|
+
if (p.sl.has(`${resource}:${op}`))
|
|
56
|
+
return true;
|
|
57
|
+
if (op === 'read' && p.sl.has(`${resource}:write`))
|
|
58
|
+
return true; // write implies read
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const stub = (method, args) => ({ __stub: true, method, args });
|
|
62
|
+
/**
|
|
63
|
+
* Build a capability-enforcing test ctx for a server function. Run your handler with it:
|
|
64
|
+
*
|
|
65
|
+
* const ctx = createFunctionTestContext({ def, caller: { userId: 'me' }, secrets: { k: 'v' } })
|
|
66
|
+
* const result = await myHandler(ctx, { method: 'POST', body: { … } })
|
|
67
|
+
*
|
|
68
|
+
* A ctx.sl / ctx.secrets / ctx.fetch call not covered by `def.capabilities` throws
|
|
69
|
+
* CapabilityError — exactly as it would in production.
|
|
70
|
+
*/
|
|
71
|
+
export function createFunctionTestContext(opts) {
|
|
72
|
+
var _a, _b, _c, _d;
|
|
73
|
+
const def = opts.def || {};
|
|
74
|
+
const parsed = parseCapabilities(def.capabilities || []);
|
|
75
|
+
const impl = opts.sl || {};
|
|
76
|
+
const logs = [];
|
|
77
|
+
const gated = (resource, op, fn, name) => async (...args) => {
|
|
78
|
+
if (!allowsSl(parsed, resource, op))
|
|
79
|
+
throw new CapabilityError(`sl:${resource}:${op}`);
|
|
80
|
+
return fn ? fn(...args) : stub(`${resource}.${name}`, args);
|
|
81
|
+
};
|
|
82
|
+
const ar = impl.appRecords || {};
|
|
83
|
+
const pr = impl.products || {};
|
|
84
|
+
const at = impl.attestations || {};
|
|
85
|
+
const sl = {
|
|
86
|
+
appRecords: {
|
|
87
|
+
create: gated('records', 'write', ar.create && ar.create.bind(ar), 'create'),
|
|
88
|
+
update: gated('records', 'write', ar.update && ar.update.bind(ar), 'update'),
|
|
89
|
+
upsert: gated('records', 'write', ar.upsert && ar.upsert.bind(ar), 'upsert'),
|
|
90
|
+
delete: gated('records', 'write', ar.delete && ar.delete.bind(ar), 'delete'),
|
|
91
|
+
get: gated('records', 'read', ar.get && ar.get.bind(ar), 'get'),
|
|
92
|
+
query: gated('records', 'read', ar.query && ar.query.bind(ar), 'query'),
|
|
93
|
+
listTypes: gated('records', 'read', ar.listTypes && ar.listTypes.bind(ar), 'listTypes'),
|
|
94
|
+
},
|
|
95
|
+
products: {
|
|
96
|
+
get: gated('products', 'read', pr.get && pr.get.bind(pr), 'get'),
|
|
97
|
+
query: gated('products', 'read', pr.query && pr.query.bind(pr), 'query'),
|
|
98
|
+
create: gated('products', 'write', pr.create && pr.create.bind(pr), 'create'),
|
|
99
|
+
update: gated('products', 'write', pr.update && pr.update.bind(pr), 'update'),
|
|
100
|
+
},
|
|
101
|
+
attestations: {
|
|
102
|
+
create: gated('attestations', 'write', at.create && at.create.bind(at), 'create'),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const secretsMap = opts.secrets || {};
|
|
106
|
+
const baseFetch = opts.fetch || (typeof fetch !== 'undefined' ? fetch : undefined);
|
|
107
|
+
const via = (def.trigger && def.trigger.type) || 'http';
|
|
108
|
+
const caller = opts.caller || {};
|
|
109
|
+
return {
|
|
110
|
+
collectionId: opts.collectionId || 'test-collection',
|
|
111
|
+
appId: opts.appId || 'test-app',
|
|
112
|
+
sl,
|
|
113
|
+
secrets: {
|
|
114
|
+
async get(ref) {
|
|
115
|
+
if (!parsed.secrets.has(ref))
|
|
116
|
+
throw new CapabilityError(`secrets:${ref}`);
|
|
117
|
+
return Object.prototype.hasOwnProperty.call(secretsMap, ref) ? secretsMap[ref] : null;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
caller: {
|
|
121
|
+
userId: (_a = caller.userId) !== null && _a !== void 0 ? _a : null,
|
|
122
|
+
anonymous: (_b = caller.anonymous) !== null && _b !== void 0 ? _b : !caller.userId,
|
|
123
|
+
origin: (_c = caller.origin) !== null && _c !== void 0 ? _c : null,
|
|
124
|
+
ip: (_d = caller.ip) !== null && _d !== void 0 ? _d : null,
|
|
125
|
+
via,
|
|
126
|
+
},
|
|
127
|
+
fetch: (async (input, init) => {
|
|
128
|
+
let host = '';
|
|
129
|
+
try {
|
|
130
|
+
host = new URL(typeof input === 'string' ? input : input.url).host;
|
|
131
|
+
}
|
|
132
|
+
catch ( /* bad URL → denied */_a) { /* bad URL → denied */ }
|
|
133
|
+
const allowed = parsed.networkAll || (!!host && parsed.networkHosts.has(host.toLowerCase()));
|
|
134
|
+
if (!allowed)
|
|
135
|
+
throw new CapabilityError(host ? `network:${host}` : 'network');
|
|
136
|
+
if (!baseFetch)
|
|
137
|
+
throw new Error('fetch is not available in this environment; pass opts.fetch');
|
|
138
|
+
return baseFetch(input, init);
|
|
139
|
+
}),
|
|
140
|
+
log: (message, data) => {
|
|
141
|
+
logs.push(Object.assign({ at: new Date().toISOString(), message: String(message) }, (data ? { data } : {})));
|
|
142
|
+
},
|
|
143
|
+
logs,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -244,6 +244,13 @@ export interface AppFunctionDef {
|
|
|
244
244
|
* `event`/`cron` functions have no caller and always run as `collection`.
|
|
245
245
|
*/
|
|
246
246
|
authority?: AppFunctionAuthority;
|
|
247
|
+
/**
|
|
248
|
+
* Required acknowledgment for the sharp edge: a `public` + `collection` function is
|
|
249
|
+
* publicly callable AND runs with elevated collection authority. Set `elevated: true`
|
|
250
|
+
* to confirm you intend that and accept responsibility for validating requests —
|
|
251
|
+
* without it, install/validation fails. Ignored for any other visibility/authority combo.
|
|
252
|
+
*/
|
|
253
|
+
elevated?: boolean;
|
|
247
254
|
/**
|
|
248
255
|
* Least-privilege capabilities this function needs, surfaced at install for
|
|
249
256
|
* consent and capped at runtime — even for `collection`-authority functions.
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0-alpha.2 | Generated: 2026-09-14T14:13:56.533Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -22,6 +22,7 @@ For detailed guides on specific features:
|
|
|
22
22
|
- **[App Configuration Files](app-manifest.md)** - `app.manifest.json` and `app.admin.json` reference — bundles, components, setup questions, import schemas, tunable fields, and metrics
|
|
23
23
|
- **[Executor Model](executor.md)** - Programmatic JS bundles for AI-driven setup, server-side SEO metadata generation, and LLM content for AI crawlers
|
|
24
24
|
- **[Server Functions](server-functions.md)** - App-authored server-side "edge functions" (`async (ctx, event) => result`): http/event/cron triggers, the visibility/authority/capabilities security model, and the pre-scoped `ctx` (authority-scoped SDK, capability-gated secrets + fetch)
|
|
25
|
+
- **[Deploying & Registering an App](deploying-apps.md)** - Publish → build → register: the app CDN layout (smartlinks.app), dev/beta/prod channels, channel-scoped deploy keys, the `POST /apps/:appId/releases` registration endpoint + validation, and how to wire it into your build so a bad manifest fails the deploy
|
|
25
26
|
- **[Realtime](realtime.md)** - Real-time data updates and WebSocket connections
|
|
26
27
|
- **[iframe Responder](iframe-responder.md)** - iframe integration and cross-origin communication
|
|
27
28
|
- **[iframe Streaming Parent Changes](iframe-streaming-parent-changes.md)** - Parent-side changes required to support AI streaming in iframe proxy mode
|
|
@@ -1722,6 +1723,7 @@ interface AppFunctionDef {
|
|
|
1722
1723
|
trigger: AppFunctionTrigger;
|
|
1723
1724
|
visibility?: AppFunctionVisibility;
|
|
1724
1725
|
authority?: AppFunctionAuthority;
|
|
1726
|
+
elevated?: boolean;
|
|
1725
1727
|
capabilities?: string[];
|
|
1726
1728
|
apiVersion?: string;
|
|
1727
1729
|
handler?: string;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Deploying & registering an app
|
|
2
|
+
|
|
3
|
+
> **Preview — SmartLinks SDK 2.0.0-alpha.** Part of the installable-app platform being built
|
|
4
|
+
> toward 2.0.0 stable. These APIs may change before then. Published under the npm `next` tag;
|
|
5
|
+
> `latest` remains 1.x.
|
|
6
|
+
|
|
7
|
+
A SmartLinks app is a bundle (widgets, containers, and — new — [server functions](server-functions.md))
|
|
8
|
+
described by an `app.manifest.json`. Deploying an app has two halves:
|
|
9
|
+
|
|
10
|
+
1. **Publish the bundles** to the SmartLinks app CDN.
|
|
11
|
+
2. **Register the release** — tell the SmartLinks backend a new version exists, hand it the
|
|
12
|
+
manifest, and let it validate everything. Registration is what makes the app (and its
|
|
13
|
+
server functions) known, installable, and testable.
|
|
14
|
+
|
|
15
|
+
This guide covers registration and how to wire it into your build.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Two "wheres": the API host vs the bundle CDN
|
|
20
|
+
|
|
21
|
+
Deploying touches two different locations — keep them distinct:
|
|
22
|
+
|
|
23
|
+
1. **The install endpoint (API host)** — *where you register*. On the **main SaaS this is
|
|
24
|
+
`https://smartlinks.app`** (the API lives under `/api/v1` — the same base your app already
|
|
25
|
+
passes to `initializeApi`). A client's isolated (VPC) environment has its **own** host; you
|
|
26
|
+
install into it by calling that host. This is the value that varies per deployment.
|
|
27
|
+
2. **The bundle CDN** — *where your built files are actually served from*, which you pass at
|
|
28
|
+
registration as **`bundleBaseUrl`**. **This depends on the channel:**
|
|
29
|
+
- **`dev` (Lovable):** your files are served by **Lovable at your published app URL** — that
|
|
30
|
+
URL is your dev `bundleBaseUrl`. Lovable *is* the CDN for dev; nothing is uploaded to
|
|
31
|
+
SmartLinks.
|
|
32
|
+
- **`prod`:** files are published to the SmartLinks app CDN at
|
|
33
|
+
`https://smartlinks.app/apps/{appId}/{version}/…` by your prod deploy step; that base is
|
|
34
|
+
your prod `bundleBaseUrl`.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
https://smartlinks.app/apps/{appId}/{version}/app.manifest.json # prod layout
|
|
38
|
+
https://smartlinks.app/apps/{appId}/{version}/functions.umd.js
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Always pass `bundleBaseUrl` explicitly so it keeps working if a location changes.
|
|
42
|
+
|
|
43
|
+
> **Who publishes the bundles?** The platform does **not** fetch or mirror them for you —
|
|
44
|
+
> `bundleBaseUrl` must already serve the files at registration time. For `dev` that's Lovable
|
|
45
|
+
> (on Publish); for `prod` that's your own CDN deploy step. Registration records *where* the
|
|
46
|
+
> bundles are + the validated manifest; it does not host anything.
|
|
47
|
+
|
|
48
|
+
## Environments & the app registry
|
|
49
|
+
|
|
50
|
+
Historically an app was **stateless everywhere**: every environment fetched the same CDN
|
|
51
|
+
manifest on the fly, so apps were agnostic to shards, clients, and VPCs. Registration changes
|
|
52
|
+
that — an app is now **installed into an environment's registry**, which is **global to that
|
|
53
|
+
environment (not per-collection / not per-shard)**.
|
|
54
|
+
|
|
55
|
+
An **environment** is one SmartLinks deployment: the main SaaS, or a client's isolated (VPC)
|
|
56
|
+
instance. Each owns its own app registry, so:
|
|
57
|
+
|
|
58
|
+
- The **install endpoint's host is the environment** — `POST https://<that-env's-api>/api/v1/apps/…`.
|
|
59
|
+
Installing into a client's VPC means calling *their* API with *their* deploy key.
|
|
60
|
+
- **Rolling out to several environments = the same call, once per target** (different API host
|
|
61
|
+
+ key each). The registration script below takes the API base as a parameter for exactly this.
|
|
62
|
+
|
|
63
|
+
> Enabling an app on a specific **collection** (and consenting to its capabilities) is a
|
|
64
|
+
> separate, per-collection step layered on top of the environment's registry.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Channels
|
|
69
|
+
|
|
70
|
+
A release is registered on one **channel**, matching how you deploy:
|
|
71
|
+
|
|
72
|
+
| Channel | Deployed from | Typical use |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `dev` | **Lovable "Publish"** | Live development / testing |
|
|
75
|
+
| `beta` | your pipeline | Staging / preview |
|
|
76
|
+
| `prod` | your **Cloud Build** toolset (from git) | Production |
|
|
77
|
+
|
|
78
|
+
A collection chooses which channel it follows, so you can point a test collection at `dev`
|
|
79
|
+
and exercise a build before it reaches `prod`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Deploy keys
|
|
84
|
+
|
|
85
|
+
Registration is authenticated by a **deploy key** — not a user login — scoped to the channels
|
|
86
|
+
it may write. Security comes from the *scope*: a leaked key can only do what its scope allows.
|
|
87
|
+
|
|
88
|
+
| Key | Where to keep it | May write | If it leaks |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| **Dev key** | a **Lovable workspace Build Secret** (below) | `dev` channel, **any app in the environment** | someone can register/overwrite `dev` releases — which are served only to test collections — but **never** `beta`/`prod` |
|
|
91
|
+
| **Prod master key** | your CI/Cloud Build secret store **only** | all channels | full control — so keep it out of app source entirely |
|
|
92
|
+
| **Per-app key** *(future)* | provisioned per app | one specific app | scoped to that one app |
|
|
93
|
+
|
|
94
|
+
**Keep the dev key in Lovable Build Secrets — do not commit it.** Lovable exposes
|
|
95
|
+
**workspace-level Build Secrets** shared across every project in the workspace: set the dev key
|
|
96
|
+
there **once** and every microapp inherits it, nothing is committed, and there's a single value
|
|
97
|
+
to rotate. (An earlier draft of this guide said to put the key in app source — don't; use Build
|
|
98
|
+
Secrets.)
|
|
99
|
+
|
|
100
|
+
**Minting (current state, 2.0.0-alpha):** the `dev` and `prod` keys are **shared secrets
|
|
101
|
+
configured on the SmartLinks backend** — one `dev` key and one `prod` key covering all
|
|
102
|
+
first-party apps. There is **no self-service minting UI yet**: ask the platform owner for the
|
|
103
|
+
`dev` key and put it in your workspace Build Secret. Per-app keys, self-service minting, and
|
|
104
|
+
rotation are planned; rotating the shared `dev` key just means updating the one Build Secret
|
|
105
|
+
(registered releases are unaffected — the key authorizes *writes*, it isn't stored on releases).
|
|
106
|
+
|
|
107
|
+
Present the key in the `x-smartlinks-deploy-key` header.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## The registration endpoint
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
POST https://smartlinks.app/api/v1/apps/{appId}/releases # main SaaS; a VPC env uses its own host
|
|
115
|
+
x-smartlinks-deploy-key: <your deploy key>
|
|
116
|
+
Content-Type: application/json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{
|
|
121
|
+
"channel": "dev", // dev | beta | prod
|
|
122
|
+
"version": "1.2.3", // from your manifest meta.version
|
|
123
|
+
"build": { "at": "2026-09-14T10:00:00Z", "gitHash": "abc1234", "builder": "lovable" },
|
|
124
|
+
"manifest": { /* your full app.manifest.json, including the functions block */ },
|
|
125
|
+
"bundleBaseUrl": "https://smartlinks.app/apps/currys/1.2.3"
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Responses
|
|
130
|
+
|
|
131
|
+
| Status | Meaning |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `200` | Registered. Body: `{ ok: true, appId, channel, version, functions: [names], registeredAt }` |
|
|
134
|
+
| `422` | Validation failed — **the build should fail**. Body: `{ ok: false, errors: [...] }` |
|
|
135
|
+
| `401` | Missing/invalid deploy key |
|
|
136
|
+
| `403` | Key not permitted for that channel (e.g. a dev key targeting `prod`) — `CHANNEL_FORBIDDEN` |
|
|
137
|
+
|
|
138
|
+
Each validation error is `{ code, message, path }`, e.g.:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{ "ok": false, "errors": [
|
|
142
|
+
{ "code": "AUTHORITY_INVALID", "message": "invalid authority \"root\"", "path": "functions.definitions[0].authority" },
|
|
143
|
+
{ "code": "CAPABILITY_INVALID", "message": "invalid capability \"bogus\"", "path": "functions.definitions[0].capabilities[0]" }
|
|
144
|
+
]}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Re-registering & version collisions
|
|
148
|
+
|
|
149
|
+
Registration is an **upsert keyed by (app, channel)** — the newest registration becomes that
|
|
150
|
+
channel's live release. Registering again **overwrites** it (last write wins); there is **no**
|
|
151
|
+
version-collision error even if `meta.version` is unchanged. So re-publishing to `dev` without
|
|
152
|
+
bumping the version is a normal, intentional overwrite — which is what you want on `dev`. (A
|
|
153
|
+
stricter `prod` policy that rejects a duplicate `version` may come later; it isn't enforced
|
|
154
|
+
today.)
|
|
155
|
+
|
|
156
|
+
### What gets validated
|
|
157
|
+
|
|
158
|
+
- `manifest.meta.appId` must match the `{appId}` in the URL.
|
|
159
|
+
- The **`functions` block** is validated with the *same* rules the runtime enforces
|
|
160
|
+
(names, triggers, `visibility`/`authority`, the capability grammar, duplicate names) — so
|
|
161
|
+
a malformed server function is caught **at deploy time**, not at runtime. See
|
|
162
|
+
[server-functions.md](server-functions.md).
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Wiring it into your build
|
|
167
|
+
|
|
168
|
+
Registration is the **last step of your build** — after bundles are built and hashed. Run a
|
|
169
|
+
small script that reads your built manifest and POSTs it, and **exits non-zero on failure**
|
|
170
|
+
so a bad install fails the publish.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
// scripts/register-release.mjs — run as the build's postbuild step
|
|
174
|
+
import { readFileSync } from 'node:fs'
|
|
175
|
+
import { execSync } from 'node:child_process'
|
|
176
|
+
|
|
177
|
+
const API = process.env.SMARTLINKS_API || 'https://smartlinks.app' // a VPC env: set to its host
|
|
178
|
+
const KEY = process.env.SMARTLINKS_DEPLOY_KEY // Lovable Build Secret (dev) / CI secret (prod)
|
|
179
|
+
const CHANNEL = process.env.SMARTLINKS_CHANNEL // 'dev' | 'beta' | 'prod'; UNSET ⇒ don't register
|
|
180
|
+
|
|
181
|
+
// --- Gate: only register when this build is meant to ---
|
|
182
|
+
// A preview / live-edit build (no channel) skips quietly so it never fails. A build that
|
|
183
|
+
// declares a channel but has no key is a hard error IF it's prod; dev skips quietly.
|
|
184
|
+
if (!CHANNEL) {
|
|
185
|
+
console.log('ℹ︎ SmartLinks: SMARTLINKS_CHANNEL unset — skipping release registration (preview build).')
|
|
186
|
+
process.exit(0)
|
|
187
|
+
}
|
|
188
|
+
if (!KEY) {
|
|
189
|
+
if (CHANNEL === 'prod') { console.error('❌ prod build but SMARTLINKS_DEPLOY_KEY is missing'); process.exit(1) }
|
|
190
|
+
console.log(`ℹ︎ SmartLinks: no deploy key for "${CHANNEL}" — skipping registration.`)
|
|
191
|
+
process.exit(0)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const manifest = JSON.parse(readFileSync('dist/app.manifest.json', 'utf8'))
|
|
195
|
+
const appId = manifest.meta.appId
|
|
196
|
+
const version = manifest.meta.version
|
|
197
|
+
const gitHash = (() => { try { return execSync('git rev-parse --short HEAD').toString().trim() } catch { return null } })()
|
|
198
|
+
// dev: your published Lovable URL; prod: the SmartLinks CDN base. Set SMARTLINKS_BUNDLE_BASE_URL.
|
|
199
|
+
const bundleBaseUrl = process.env.SMARTLINKS_BUNDLE_BASE_URL || `https://smartlinks.app/apps/${appId}/${version}`
|
|
200
|
+
|
|
201
|
+
const res = await fetch(`${API}/api/v1/apps/${appId}/releases`, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
channel: CHANNEL, version, manifest, bundleBaseUrl,
|
|
206
|
+
build: { at: new Date().toISOString(), gitHash, builder: CHANNEL === 'dev' ? 'lovable' : 'ci' },
|
|
207
|
+
}),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const body = await res.json().catch(() => ({}))
|
|
211
|
+
if (!res.ok || !body.ok) {
|
|
212
|
+
console.error(`❌ SmartLinks registration failed (${res.status}):`)
|
|
213
|
+
for (const e of body.errors || []) console.error(` • ${e.path}: ${e.message}`)
|
|
214
|
+
process.exit(1) // fail the build
|
|
215
|
+
}
|
|
216
|
+
console.log(`✅ Registered ${appId}@${version} on "${CHANNEL}" — functions: ${(body.functions || []).join(', ') || 'none'}`)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Wire it after your bundle build/hash step:
|
|
220
|
+
|
|
221
|
+
```jsonc
|
|
222
|
+
// package.json
|
|
223
|
+
"scripts": {
|
|
224
|
+
"build": "vite build && … && node scripts/hash-bundles.mjs",
|
|
225
|
+
"postbuild": "node scripts/register-release.mjs"
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Registration is gated by **`SMARTLINKS_CHANNEL`**, so the three Lovable build types behave correctly:
|
|
230
|
+
|
|
231
|
+
| Build | `SMARTLINKS_CHANNEL` | Result |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| **Preview / live-edit** | unset | **skips quietly** — never registers, never fails |
|
|
234
|
+
| **Dev (Publish)** | `dev` | registers to `dev` with the workspace Build-Secret key + your Lovable `SMARTLINKS_BUNDLE_BASE_URL` |
|
|
235
|
+
| **Prod (CI)** | `prod` | registers to `prod` with the prod key; a missing key **hard-fails** |
|
|
236
|
+
|
|
237
|
+
The gate is `SMARTLINKS_CHANNEL`, so **set it only where you want a release** — i.e. on the
|
|
238
|
+
Publish/CI build, not on preview. If your host exposes a publish-only signal (an env var it sets
|
|
239
|
+
only on Publish), key `SMARTLINKS_CHANNEL` off that; otherwise set it in the Publish build's env
|
|
240
|
+
and leave it unset for preview. That one variable is the difference between "this build ships a
|
|
241
|
+
release" and "this build is just a preview."
|
|
242
|
+
|
|
243
|
+
That's it: a real Publish validates + registers your app (a broken manifest or function stops
|
|
244
|
+
the deploy with an actionable error), while preview builds stay quiet.
|
package/docs/server-functions.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Server functions ("edge functions")
|
|
2
2
|
|
|
3
|
+
> **Preview — SmartLinks SDK 2.0.0-alpha.** Part of the installable-app platform being built
|
|
4
|
+
> toward 2.0.0 stable (author → register → install → run → test). These APIs may change before
|
|
5
|
+
> then. Published under the npm `next` tag; `latest` remains 1.x.
|
|
6
|
+
|
|
3
7
|
A **server function** is arbitrary server-side JavaScript your app deploys directly into
|
|
4
8
|
SmartLinks. It runs on the SmartLinks servers — with access to the full SDK, to your app's
|
|
5
9
|
secrets, and to outbound network — so you can do things a browser app can't: validate and
|
|
@@ -38,6 +42,7 @@ in a bundle alongside your widgets/containers:
|
|
|
38
42
|
"trigger": { "type": "http", "methods": ["POST"] },
|
|
39
43
|
"visibility": "public", // WHO may call it
|
|
40
44
|
"authority": "collection", // WHOSE authority it runs as
|
|
45
|
+
"elevated": true, // required ack for public + collection
|
|
41
46
|
"capabilities": ["sl:records:write", "network:api.recaptcha.net"],
|
|
42
47
|
"apiVersion": "2026-09"
|
|
43
48
|
},
|
|
@@ -104,6 +109,10 @@ functions have no external caller, so they always run as `collection`.
|
|
|
104
109
|
> secrets, even though it's "elevated"). But **validating the request is still your job**:
|
|
105
110
|
> check the payload, rate-limit using `ctx.caller`, guard against replay. Treat the function
|
|
106
111
|
> body as a trust boundary.
|
|
112
|
+
>
|
|
113
|
+
> Because it's the sharp edge, a `public` + `collection` function must **explicitly opt in**
|
|
114
|
+
> with `elevated: true` in its declaration — a conscious acknowledgment that you're exposing
|
|
115
|
+
> collection authority to public callers. Without it, install/validation fails.
|
|
107
116
|
|
|
108
117
|
---
|
|
109
118
|
|
|
@@ -219,6 +228,69 @@ at admin level, attributed to the signed-in admin. The public surface resolves a
|
|
|
219
228
|
token is present (→ `owner`) and treats its absence as anonymous (→ `public`); a
|
|
220
229
|
`collection`-authority function runs elevated regardless.
|
|
221
230
|
|
|
231
|
+
## Testing & preview
|
|
232
|
+
|
|
233
|
+
You don't have to deploy to find out whether a function works. There are three levels of
|
|
234
|
+
fidelity — use them in order.
|
|
235
|
+
|
|
236
|
+
### 1. Local harness (fast, offline)
|
|
237
|
+
|
|
238
|
+
`@proveanything/smartlinks/testing` builds a `ctx` that **enforces the declared capability
|
|
239
|
+
envelope**, so a function fails locally the same way it would in production — the common
|
|
240
|
+
"I forgot to declare `sl:records:write`" bug is caught before you deploy, not after.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { createFunctionTestContext } from '@proveanything/smartlinks/testing'
|
|
244
|
+
import manifest from '../public/app.manifest.json'
|
|
245
|
+
import { submitCompetitionEntry } from '../src/functions'
|
|
246
|
+
|
|
247
|
+
const def = manifest.functions.definitions.find(d => d.name === 'submitCompetitionEntry')
|
|
248
|
+
|
|
249
|
+
const ctx = createFunctionTestContext({
|
|
250
|
+
def, // capabilities enforced come from the manifest itself
|
|
251
|
+
caller: { userId: 'tester' },
|
|
252
|
+
secrets: { 'recaptcha-secret': 'test-value' }, // fixtures — real secrets are server-only
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const res = await submitCompetitionEntry(ctx, { method: 'POST', body: { email: 'a@b.com', answer: '42' } })
|
|
256
|
+
// ctx.sl.appRecords.create(...) throws CapabilityError unless `def` declares sl:records:write
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Pass the **`def`** (not a hand-typed capability list) so "tested" can't drift from
|
|
260
|
+
"declared". By default `ctx.sl` methods return a stub result (pure unit test — no network);
|
|
261
|
+
inject `sl` to delegate to your live SDK for real reads/writes:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const ctx = createFunctionTestContext({
|
|
265
|
+
def,
|
|
266
|
+
sl: { appRecords: { create: (fields) => mySdk.app.records.create(fields) } },
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### 2. Deployed test mode (high fidelity, safe)
|
|
271
|
+
|
|
272
|
+
Register to the `dev` channel and invoke on the real server — real secrets, real data —
|
|
273
|
+
without a live run *(coming next)*: a test invocation is forced to `caller` authority,
|
|
274
|
+
side-effecting writes are dry-run, and the traffic is logged separately from live metrics.
|
|
275
|
+
|
|
276
|
+
### 3. Live
|
|
277
|
+
|
|
278
|
+
Point a real collection at the channel and invoke for real.
|
|
279
|
+
|
|
280
|
+
### What differs across the three
|
|
281
|
+
|
|
282
|
+
| | Capabilities | `ctx.sl` | Secrets | Authority | Writes |
|
|
283
|
+
|---|---|---|---|---|---|
|
|
284
|
+
| **Local harness** | Enforced (from `def`) | Stub, or your injected SDK | Fixtures you pass | Informational | Whatever your impl does |
|
|
285
|
+
| **Deployed test** | Enforced | Real (test-scoped) | Real | Forced to `caller` | Dry-run |
|
|
286
|
+
| **Live** | Enforced | Real | Real | As declared | Real |
|
|
287
|
+
|
|
288
|
+
### Recommended CI pattern
|
|
289
|
+
|
|
290
|
+
1. **Unit** — run each handler through `createFunctionTestContext` (no network); assert
|
|
291
|
+
behaviour *and* that capabilities are sufficient (an under-declared capability throws).
|
|
292
|
+
2. **Post-deploy smoke** — after registering to `dev`, hit each function once in test mode.
|
|
293
|
+
|
|
222
294
|
## Where functions run (and why it doesn't change how you write them)
|
|
223
295
|
|
|
224
296
|
SmartLinks runs first-party (trusted) functions **in-process** and untrusted third-party
|
package/openapi.yaml
CHANGED
|
@@ -17281,6 +17281,8 @@ components:
|
|
|
17281
17281
|
$ref: "#/components/schemas/AppFunctionVisibility"
|
|
17282
17282
|
authority:
|
|
17283
17283
|
$ref: "#/components/schemas/AppFunctionAuthority"
|
|
17284
|
+
elevated:
|
|
17285
|
+
type: boolean
|
|
17284
17286
|
capabilities:
|
|
17285
17287
|
type: array
|
|
17286
17288
|
items:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proveanything/smartlinks",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-alpha.2",
|
|
4
4
|
"description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./testing": {
|
|
13
|
+
"types": "./dist/testing/index.d.ts",
|
|
14
|
+
"default": "./dist/testing/index.js"
|
|
11
15
|
}
|
|
12
16
|
},
|
|
13
17
|
"files": [
|
|
@@ -37,7 +41,8 @@
|
|
|
37
41
|
"author": "Glenn Shoosmith",
|
|
38
42
|
"license": "MIT",
|
|
39
43
|
"publishConfig": {
|
|
40
|
-
"access": "public"
|
|
44
|
+
"access": "public",
|
|
45
|
+
"tag": "next"
|
|
41
46
|
},
|
|
42
47
|
"dependencies": {
|
|
43
48
|
"cross-fetch": "^3.1.5"
|