@proveanything/smartlinks 1.17.5 → 2.0.0-alpha.1

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.
@@ -0,0 +1,193 @@
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*. This is a SmartLinks
24
+ **API v1** endpoint, and its **host is the environment** you're installing into (see
25
+ below). This is the one that varies per deployment.
26
+ 2. **The bundle CDN** — *where your files are served from*. **Today always `smartlinks.app`**:
27
+
28
+ ```
29
+ https://smartlinks.app/apps/{appId}/{version}/app.manifest.json
30
+ https://smartlinks.app/apps/{appId}/{version}/widgets-<hash>.umd.js
31
+ https://smartlinks.app/apps/{appId}/{version}/functions.umd.js
32
+ ```
33
+
34
+ That base — `https://smartlinks.app/apps/{appId}/{version}` — is your **`bundleBaseUrl`**,
35
+ which you pass at registration. Always pass it explicitly so nothing breaks if the CDN
36
+ location changes later.
37
+
38
+ ## Environments & the app registry
39
+
40
+ Historically an app was **stateless everywhere**: every environment fetched the same CDN
41
+ manifest on the fly, so apps were agnostic to shards, clients, and VPCs. Registration changes
42
+ that — an app is now **installed into an environment's registry**, which is **global to that
43
+ environment (not per-collection / not per-shard)**.
44
+
45
+ An **environment** is one SmartLinks deployment: the main SaaS, or a client's isolated (VPC)
46
+ instance. Each owns its own app registry, so:
47
+
48
+ - The **install endpoint's host is the environment** — `POST https://<that-env's-api>/api/v1/apps/…`.
49
+ Installing into a client's VPC means calling *their* API with *their* deploy key.
50
+ - **Rolling out to several environments = the same call, once per target** (different API host
51
+ + key each). The registration script below takes the API base as a parameter for exactly this.
52
+
53
+ > Enabling an app on a specific **collection** (and consenting to its capabilities) is a
54
+ > separate, per-collection step layered on top of the environment's registry.
55
+
56
+ ---
57
+
58
+ ## Channels
59
+
60
+ A release is registered on one **channel**, matching how you deploy:
61
+
62
+ | Channel | Deployed from | Typical use |
63
+ |---|---|---|
64
+ | `dev` | **Lovable "Publish"** | Live development / testing |
65
+ | `beta` | your pipeline | Staging / preview |
66
+ | `prod` | your **Cloud Build** toolset (from git) | Production |
67
+
68
+ A collection chooses which channel it follows, so you can point a test collection at `dev`
69
+ and exercise a build before it reaches `prod`.
70
+
71
+ ---
72
+
73
+ ## Deploy keys
74
+
75
+ Registration is authenticated by a **deploy key** — not a user login — scoped to the
76
+ channels it may write. Security comes from the *scope*, so a key that leaks can only affect
77
+ what it was allowed to touch.
78
+
79
+ | Key | Lives in | May write |
80
+ |---|---|---|
81
+ | **Dev key** | your app's (private) source / build env | `dev` channel only |
82
+ | **Prod master key** | your Cloud Build toolset only — never in app source | all channels |
83
+ | **Per-app key** *(future)* | a client's build | one specific app |
84
+
85
+ Because a Lovable build has no secret store, the **dev key lives in your app source** — which
86
+ is safe precisely because it can only ever write the `dev` channel. The **prod key stays in
87
+ Cloud Build**. Present the key in the `x-smartlinks-deploy-key` header.
88
+
89
+ ---
90
+
91
+ ## The registration endpoint
92
+
93
+ ```
94
+ POST https://<smartlinks-api>/api/v1/apps/{appId}/releases
95
+ x-smartlinks-deploy-key: <your deploy key>
96
+ Content-Type: application/json
97
+ ```
98
+
99
+ ```jsonc
100
+ {
101
+ "channel": "dev", // dev | beta | prod
102
+ "version": "1.2.3", // from your manifest meta.version
103
+ "build": { "at": "2026-09-14T10:00:00Z", "gitHash": "abc1234", "builder": "lovable" },
104
+ "manifest": { /* your full app.manifest.json, including the functions block */ },
105
+ "bundleBaseUrl": "https://smartlinks.app/apps/currys/1.2.3"
106
+ }
107
+ ```
108
+
109
+ ### Responses
110
+
111
+ | Status | Meaning |
112
+ |---|---|
113
+ | `200` | Registered. Body: `{ ok: true, appId, channel, version, functions: [names], registeredAt }` |
114
+ | `422` | Validation failed — **the build should fail**. Body: `{ ok: false, errors: [...] }` |
115
+ | `401` | Missing/invalid deploy key |
116
+ | `403` | Key not permitted for that channel (e.g. a dev key targeting `prod`) — `CHANNEL_FORBIDDEN` |
117
+
118
+ Each validation error is `{ code, message, path }`, e.g.:
119
+
120
+ ```json
121
+ { "ok": false, "errors": [
122
+ { "code": "AUTHORITY_INVALID", "message": "invalid authority \"root\"", "path": "functions.definitions[0].authority" },
123
+ { "code": "CAPABILITY_INVALID", "message": "invalid capability \"bogus\"", "path": "functions.definitions[0].capabilities[0]" }
124
+ ]}
125
+ ```
126
+
127
+ ### What gets validated
128
+
129
+ - `manifest.meta.appId` must match the `{appId}` in the URL.
130
+ - The **`functions` block** is validated with the *same* rules the runtime enforces
131
+ (names, triggers, `visibility`/`authority`, the capability grammar, duplicate names) — so
132
+ a malformed server function is caught **at deploy time**, not at runtime. See
133
+ [server-functions.md](server-functions.md).
134
+
135
+ ---
136
+
137
+ ## Wiring it into your build
138
+
139
+ Registration is the **last step of your build** — after bundles are built and hashed. Run a
140
+ small script that reads your built manifest and POSTs it, and **exits non-zero on failure**
141
+ so a bad install fails the publish.
142
+
143
+ ```js
144
+ // scripts/register-release.mjs — run as the build's postbuild step
145
+ import { readFileSync } from 'node:fs'
146
+ import { execSync } from 'node:child_process'
147
+
148
+ const API = process.env.SMARTLINKS_API || 'https://api.smartlinks.app'
149
+ const KEY = process.env.SMARTLINKS_DEPLOY_KEY // dev key (in source/env) or prod key (Cloud Build)
150
+ const CHANNEL = process.env.SMARTLINKS_CHANNEL || 'dev' // 'prod' in Cloud Build
151
+
152
+ const manifest = JSON.parse(readFileSync('dist/app.manifest.json', 'utf8'))
153
+ const appId = manifest.meta.appId
154
+ const version = manifest.meta.version
155
+ const gitHash = (() => { try { return execSync('git rev-parse --short HEAD').toString().trim() } catch { return null } })()
156
+
157
+ const res = await fetch(`${API}/api/v1/apps/${appId}/releases`, {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
160
+ body: JSON.stringify({
161
+ channel: CHANNEL,
162
+ version,
163
+ build: { at: new Date().toISOString(), gitHash, builder: CHANNEL === 'dev' ? 'lovable' : 'cloudbuild' },
164
+ manifest,
165
+ bundleBaseUrl: `https://smartlinks.app/apps/${appId}/${version}`,
166
+ }),
167
+ })
168
+
169
+ const body = await res.json().catch(() => ({}))
170
+ if (!res.ok || !body.ok) {
171
+ console.error(`❌ SmartLinks registration failed (${res.status}):`)
172
+ for (const e of body.errors || []) console.error(` • ${e.path}: ${e.message}`)
173
+ process.exit(1) // fail the build
174
+ }
175
+ console.log(`✅ Registered ${appId}@${version} on "${CHANNEL}" — functions: ${(body.functions || []).join(', ') || 'none'}`)
176
+ ```
177
+
178
+ Wire it after your bundle build/hash step, e.g.:
179
+
180
+ ```jsonc
181
+ // package.json
182
+ "scripts": {
183
+ "build": "vite build && … && node scripts/hash-bundles.mjs",
184
+ "postbuild": "node scripts/register-release.mjs"
185
+ }
186
+ ```
187
+
188
+ - **Dev (Lovable "Publish")** runs `build` → `postbuild` with the **dev key** and
189
+ `SMARTLINKS_CHANNEL=dev`.
190
+ - **Prod (Cloud Build)** runs the same with the **prod key** and `SMARTLINKS_CHANNEL=prod`.
191
+
192
+ That's it: hitting Publish now validates and registers your app — and a broken manifest or
193
+ function stops the deploy with an actionable error instead of shipping.
@@ -0,0 +1,306 @@
1
+ # Server functions ("edge functions")
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
+
7
+ A **server function** is arbitrary server-side JavaScript your app deploys directly into
8
+ SmartLinks. It runs on the SmartLinks servers — with access to the full SDK, to your app's
9
+ secrets, and to outbound network — so you can do things a browser app can't: validate and
10
+ write on the server, call third-party systems with credentials the client never sees, react
11
+ to events, and run scheduled work.
12
+
13
+ Every server function has the **same shape**, no matter how it's triggered or where it runs:
14
+
15
+ ```ts
16
+ export async function myFunction(ctx, event) {
17
+ // ctx — a SmartLinks SDK pre-scoped to your declared authority, plus secrets, caller, fetch, log
18
+ // event — the trigger payload (the HTTP body, the event, or the cron tick)
19
+ return { ok: true } // returned to the caller (http) or recorded as the run result (event/cron)
20
+ }
21
+ ```
22
+
23
+ You never receive a raw API key or a superuser client. The platform builds `ctx` fresh for
24
+ each invocation and **pre-scopes every handle to exactly what your function declared** — this
25
+ is how a function stays safe even when it runs with elevated authority.
26
+
27
+ ---
28
+
29
+ ## Declaring a function
30
+
31
+ Functions are declared in your `app.manifest.json` under `functions`, and the handlers ship
32
+ in a bundle alongside your widgets/containers:
33
+
34
+ ```jsonc
35
+ {
36
+ "functions": {
37
+ "files": { "js": { "umd": "dist/functions.umd.js" } },
38
+ "definitions": [
39
+ {
40
+ "name": "submitCompetitionEntry",
41
+ "description": "Validate a competition entry and record it server-side.",
42
+ "trigger": { "type": "http", "methods": ["POST"] },
43
+ "visibility": "public", // WHO may call it
44
+ "authority": "collection", // WHOSE authority it runs as
45
+ "elevated": true, // required ack for public + collection
46
+ "capabilities": ["sl:records:write", "network:api.recaptcha.net"],
47
+ "apiVersion": "2026-09"
48
+ },
49
+ {
50
+ "name": "onProofCreated",
51
+ "trigger": { "type": "event", "eventTypes": ["proof.created"] },
52
+ "capabilities": ["sl:records:write", "secrets:crm-key", "network"]
53
+ },
54
+ {
55
+ "name": "nightlyReconcile",
56
+ "trigger": { "type": "cron", "schedule": "0 2 * * *" },
57
+ "capabilities": ["sl:products:read", "sl:records:write"]
58
+ }
59
+ ]
60
+ }
61
+ }
62
+ ```
63
+
64
+ Each definition maps to an exported handler of the same `name` (override with `handler`).
65
+
66
+ ---
67
+
68
+ ## The two security questions every function answers
69
+
70
+ Server-side code needs two questions answered up front. SmartLinks makes both **explicit and
71
+ declarative** — you state them in the manifest, and the platform enforces them. This is the
72
+ same split every extensible platform lands on (Salesforce `with/without sharing`, Shopify
73
+ online/offline tokens, Lambda's invoke-policy vs execution-role).
74
+
75
+ ### 1. `visibility` — who is allowed to call it? *(http only)*
76
+
77
+ | Value | Meaning |
78
+ |---|---|
79
+ | `admin` *(default)* | Callable only from an authenticated admin surface. |
80
+ | `public` | Publicly callable — no authenticated user required. |
81
+
82
+ ### 2. `authority` — whose authority does it run as?
83
+
84
+ This decides what `ctx.sl` can do.
85
+
86
+ | Value | `ctx.sl` is scoped to | Use when |
87
+ |---|---|---|
88
+ | `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. |
89
+ | `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. |
90
+
91
+ `authority` **defaults to `caller`** — secure by default. `event`- and `cron`-triggered
92
+ functions have no external caller, so they always run as `collection`.
93
+
94
+ ### The combinations
95
+
96
+ - **`http` + `admin` + `caller`** — a delegated admin function; runs as the signed-in admin.
97
+ - **`http` + `public` + `caller`** — runs as the anonymous/public user (limited). The safe public default.
98
+ - **`http` + `public` + `collection`** — publicly callable, runs with collection authority.
99
+ The powerful one (e.g. "submit competition entry" needs to validate then write a record no
100
+ anonymous user may write directly). **Your responsibility:** validate the input and prevent
101
+ abuse — see below.
102
+ - **`event` / `cron`** — background work; runs as `collection`.
103
+
104
+ > ### ⚠️ Public + collection authority is the sharp edge
105
+ > A `public` + `collection` function is reachable by anyone and runs with elevated authority.
106
+ > The platform shrinks the blast radius for you — the authority is capped to **this one
107
+ > collection**, and further capped by your declared **capabilities** (a competition-entry
108
+ > function that declares only `sl:records:write` cannot delete products or read other
109
+ > secrets, even though it's "elevated"). But **validating the request is still your job**:
110
+ > check the payload, rate-limit using `ctx.caller`, guard against replay. Treat the function
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.
116
+
117
+ ---
118
+
119
+ ## Capabilities — least privilege, declared and capped
120
+
121
+ `capabilities` is the allow-list of what your function may reach. It is surfaced at install
122
+ time for consent and **enforced at runtime** — including for `collection`-authority functions.
123
+ Declare the minimum you need.
124
+
125
+ | Capability | Grants |
126
+ |---|---|
127
+ | `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`. |
128
+ | `network` | `ctx.fetch` to any host. |
129
+ | `network:<host>` | `ctx.fetch` to that host only (repeat for several). Prefer this over blanket `network`. |
130
+ | `secrets:<ref>` | `ctx.secrets.get('<ref>')` for that one secret ref. |
131
+
132
+ If you don't declare `network`, `ctx.fetch` is absent. If you don't declare a `secrets:<ref>`,
133
+ `ctx.secrets.get('<ref>')` returns `null`.
134
+
135
+ ---
136
+
137
+ ## The `ctx` object
138
+
139
+ ```ts
140
+ interface ServerFunctionContext {
141
+ collectionId: string
142
+ appId: string
143
+
144
+ /** SmartLinks SDK, pre-scoped to your declared `authority`. Capabilities cap what it may do. */
145
+ sl: SmartLinks
146
+
147
+ /** Capability-gated secrets. Resolves only refs you declared via `secrets:<ref>`. */
148
+ secrets: { get(ref: string): Promise<string | null> }
149
+
150
+ /** Who invoked this function. */
151
+ caller: {
152
+ userId: string | null // null for anonymous/public/system calls
153
+ anonymous: boolean
154
+ origin?: string | null // http
155
+ ip?: string | null // http
156
+ via: 'http' | 'event' | 'cron'
157
+ }
158
+
159
+ /** Outbound HTTP — present only if you declared `network` (host-scoped if `network:<host>`). */
160
+ fetch: typeof fetch
161
+
162
+ /** Structured logging, captured into your function's run telemetry. */
163
+ log: (message: string, data?: Record<string, any>) => void
164
+ }
165
+ ```
166
+
167
+ **Use `ctx.sl` for anything SmartLinks** — it is the same SDK surface you use client-side,
168
+ already authenticated as your declared authority and scoped to the collection. Do **not** try
169
+ to construct your own SDK client or carry your own key; that's what `ctx.sl` is for, and it's
170
+ the only way authority stays correct.
171
+
172
+ ---
173
+
174
+ ## Worked example — a public competition entry
175
+
176
+ ```ts
177
+ // dist/functions — handler for the manifest definition above
178
+ export async function submitCompetitionEntry(ctx, event) {
179
+ const { name, email, answer, captchaToken } = event.body || {}
180
+
181
+ // 1. Validate — this is YOUR job on a public function.
182
+ if (!email || !answer) return { ok: false, error: 'missing_fields' }
183
+
184
+ // 2. Use a declared secret + declared host to verify a captcha.
185
+ const secret = await ctx.secrets.get('recaptcha-secret')
186
+ const verify = await ctx.fetch('https://api.recaptcha.net/verify', {
187
+ method: 'POST',
188
+ body: new URLSearchParams({ secret, response: captchaToken }),
189
+ }).then(r => r.json())
190
+ if (!verify.success) return { ok: false, error: 'captcha_failed' }
191
+
192
+ // 3. Write with collection authority — something an anonymous caller can't do directly.
193
+ const entry = await ctx.sl.appRecords.create({
194
+ recordType: 'competition-entry',
195
+ data: { name, email, answer, ip: ctx.caller.ip, submittedAt: new Date().toISOString() },
196
+ })
197
+
198
+ ctx.log('entry recorded', { entryId: entry.id })
199
+ return { ok: true, entryId: entry.id }
200
+ }
201
+ ```
202
+
203
+ Declared as `public` + `collection` + `['sl:records:write', 'secrets:recaptcha-secret',
204
+ 'network:api.recaptcha.net']`, this function is publicly callable, verifies the request
205
+ itself, and writes a record no anonymous user could write — but it cannot touch products,
206
+ other secrets, or any other collection.
207
+
208
+ ---
209
+
210
+ ## Invoking an http function
211
+
212
+ An `http` function is called by POSTing to the collection's functions endpoint on the
213
+ surface that matches its `visibility`:
214
+
215
+ ```
216
+ POST /admin/collection/:collectionId/functions/:name # visibility: admin (collection-admin auth)
217
+ POST /public/collection/:collectionId/functions/:name # visibility: public (auth optional)
218
+ ```
219
+
220
+ The request body is delivered to the handler as `event.body` (query string as
221
+ `event.query`). A function only runs on its own surface — calling an `admin` function on
222
+ the public endpoint is a `403`. The response is `{ ok: true, result }` on success, or
223
+ `{ error, message }` (HTTP 400) if the handler returned an error. `GET` on either endpoint
224
+ lists the functions callable on that surface.
225
+
226
+ The admin surface is collection-admin gated, so an admin function's `caller` authority runs
227
+ at admin level, attributed to the signed-in admin. The public surface resolves auth if a
228
+ token is present (→ `owner`) and treats its absence as anonymous (→ `public`); a
229
+ `collection`-authority function runs elevated regardless.
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
+
294
+ ## Where functions run (and why it doesn't change how you write them)
295
+
296
+ SmartLinks runs first-party (trusted) functions **in-process** and untrusted third-party
297
+ functions in an **isolated runner**. The difference is enforcement, not authoring:
298
+
299
+ - **In-process** trusts the author; `ctx` is built directly.
300
+ - **Isolated runner** enforces the `authority` boundary and `capabilities` at the container
301
+ edge — `ctx.sl` is a proxy over the declared authority, `ctx.fetch` is filtered to the
302
+ declared hosts, and there is no ambient filesystem, network, or environment.
303
+
304
+ Because the **contract is identical**, a function you write today runs unchanged if it later
305
+ moves lanes. Write to the `ctx` contract and declare your capabilities honestly, and the
306
+ platform takes care of the rest.