@sparelabs/sightline-extension-cli 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -18,9 +18,11 @@ You need Node 20.19+, [Deno](https://deno.com) (the extension runtime) and Docke
18
18
  | `build` | Validates `sightline.extension.json` against the published contract (`@sparelabs/sightline-extension-manifest`) and the rules Sightline applies at publish (trust `T2`, a hosted runtime, a kebab-case id, `MAJOR.MINOR.PATCH`), checks `migrations/NNNN_<name>.sql` (numbered from `0001`, no gaps, at most 256 KB each), bundles `server/main.ts` into one ESM file (`runtime.entry`, usually `dist/server.mjs`, at most 5 MB) and lints the bundle with the same `lintBundle` Sightline runs at publish (from `@sparelabs/sightline-extension-manifest`: no remote, package or relative imports, no computed dynamic import). With `ui.frames` in the manifest it also reads your UI build from `dist/ui` (set `"ui": { "dir": … }` in `sightline-ext.json` to move it; build the UI with your own tool first) and checks it with Sightline's publish rules: allowed file types, sizes, no scripts from other origins, no root-absolute URLs (use a relative base, e.g. Vite `base: './'`), the entry HTML present. |
19
19
  | `dev [--port <n>] [--core-port 8787] [--no-db]` | Builds, starts the emulated core (`@sparelabs/sightline-extension-devkit`), your Postgres with your migrations, and the bundle under the **same Deno permission flags as production**; rebuilds and restarts on every change. `npx sightline-devkit invoke tool <name> --as user:alice` calls it. |
20
20
  | `test [--no-build] [--no-db] [--keep-db]` | Builds, runs the extension against the emulator and its own database, and checks it against the HTTP contract (`@sparelabs/sightline-extension-testing`): health, token refusal, the `ToolResult` envelope, routing. Exit 1 on any failure. |
21
- | `deploy [--bindings bindings.json] [--no-build]` | Builds, publishes the release to a workspace (identical bytes are a no-op) and installs it. Refuses when another version is installed: use `upgrade`. |
22
- | `upgrade [--bindings bindings.json] [--no-build]` | Builds, publishes, and moves the installed extension to the manifest's version. A permission the new version adds needs a binding. |
21
+ | `deploy [--no-build]` | Builds, publishes the release to a workspace (identical bytes are a no-op) and installs it. Refuses when another version is installed: use `upgrade`. |
22
+ | `upgrade [--no-build]` | Builds, publishes, and moves the installed extension to the manifest's version. A default access line the new version adds needs an Administrator's approval first. |
23
23
  | `uninstall [<id>] [--purge --yes]` | Uninstalls. Data is kept 30 days; `--purge --yes` drops it now. |
24
+ | `yank <version> [--reason "…"] [--id <ext>]` | Withdraws a published release: it can no longer be installed, upgraded or rolled back to. An installation running it keeps running, flagged (the CLI warns) until you upgrade it. Idempotent; there is no un-yank. A deploy token needs the `yank` action. |
25
+ | `run <job> [--no-wait] [--timeout <s>] [--id <ext>]` | Starts a run of the job now, outside its schedule, then reads its status every 2 s until it finishes (a job that continues makes several calls under one run), printing each call (trigger, status, HTTP status, duration, error) and the result. Exit 1 unless it `succeeded`. `--no-wait` returns once it started; past `--timeout` (default 900 s) the CLI stops waiting, the run goes on. A deploy token needs the `run_job` action. |
24
26
  | `webhooks send <name> --body <file> [--url <webhook URL>] [--secret-env VAR] [--content-type …] [--timestamp <unix s>]` | Signs the file's raw bytes the way Sightline verifies the manifest webhook `<name>` (HMAC-SHA256 hex after its `prefix`, over `<timestamp>.<raw body>` when it declares a `timestampHeader`) and POSTs them. Without `--url` it goes to the running `dev`, whose emulator verifies it like Sightline (401 with the reason when it fails, 409 for a replay) and forwards it to your `/hooks/<name>`; with `--url` (the webhook URL from the workspace's Setup) to a real workspace. The secret comes from `SL_SECRET_<NAME>` or `--secret-env VAR`, never from a flag; locally `devSecrets` win over the environment, as `dev` injects them, so the delivery is signed with the value the emulator verifies with. Exit 1 on a non-2xx answer. |
25
27
 
26
28
  Common options: `--dir <project>`, `--manifest <file>`, `--config <file>`, `--entry <file>`.
@@ -33,23 +35,43 @@ Local only; never deployed.
33
35
  {
34
36
  "entry": "server/main.ts",
35
37
  "contract": {
36
- "tool": { "name": "notes_list", "input": { "limit": 5 }, "as": "user:alice" },
38
+ "tool": { "name": "notes_list", "input": { "limit": 5 }, "as": "user:alice", "grants": ["my-notes.read"] },
39
+ "permissions": [
40
+ { "tool": "notes_add", "input": { "text": "hi" }, "grants": ["my-notes.write"] },
41
+ { "api": "GET /notes", "grants": ["my-notes.read"] }
42
+ ],
37
43
  "job": { "name": "weekly_digest" },
38
44
  "hook": { "name": "inbound", "body": { "event": "ping" } }
39
45
  },
40
46
  "settings": { "greeting": "hello" },
41
- "devSecrets": { "inbound_hook": "dev-only-value" }
47
+ "devSecrets": { "inbound_hook": "dev-only-value" },
48
+ "users": [{ "name": "alice", "email": "alice@example.test", "grants": ["my-notes.read", "my-notes.write"] }, { "name": "eve" }],
49
+ "workspace": { "name": "Acme", "timezone": "Europe/London", "locale": "en-GB", "weekStart": 1 },
50
+ "people": [{ "displayName": "Ada Lovelace", "title": "Engineer", "department": "R&D" }],
51
+ "coreScopes": ["core:people:read"]
42
52
  }
43
53
  ```
44
54
 
45
- Without `contract.tool`, `test` calls the manifest's first tool as `user:alice` with `{}`.
55
+ | Field | `dev` and `test` |
56
+ | --- | --- |
57
+ | `contract.tool` | The tool `test` calls (default: the manifest's first, as `user:alice` with `{}`); `grants`: what the caller holds for it. |
58
+ | `contract.permissions` | Each permission-checked tool or `/api` route (`"<METHOD> /path"`) must succeed holding `grants` and be refused holding nothing (`permission_denied` / `scope_missing`, or 403). Called as `as`, default `user:contract-tester`. |
59
+ | `contract.job`, `contract.hook` | A job and a hook `test` routes as the installation. |
60
+ | `settings` | What `GET /v1/installation/settings` answers. |
61
+ | `devSecrets` | Injected as `SL_SECRET_<NAME>` (over the same variables from your environment); the emulator verifies signed webhooks with them. |
62
+ | `users` | The emulated people: their grants are their tokens' scopes, `access` their bits on your resources. Default: alice and bob, holding nothing. |
63
+ | `workspace` | What `/v1/me` reports (default "Dev workspace", UTC, en-US, Sunday). |
64
+ | `people` | The people directory (`/v1/people`, `/v1/people/:id`): `displayName`, optional `id` (a uuid; default derived from the name, so `alice` is `user:alice`), `avatarUrl`, `title`, `department`, `active`. Default: one person per user. |
65
+ | `coreScopes` | The core scopes the installation holds (default: the manifest's `scopes.required`, which installing grants). The directory needs `core:people:read`. |
66
+
67
+ The emulator also gets the manifest, so its webhooks are verified like Sightline's.
46
68
 
47
69
  ## Deploying
48
70
 
49
71
  ```bash
50
72
  export SIGHTLINE_URL=https://<your workspace>
51
73
  export SIGHTLINE_DEPLOY_TOKEN=… # issued by a workspace admin; only ever read from the environment
52
- npx sightline-ext deploy --bindings bindings.json
74
+ npx sightline-ext deploy
53
75
  ```
54
76
 
55
77
  | Variable | Meaning |
@@ -59,7 +81,7 @@ npx sightline-ext deploy --bindings bindings.json
59
81
  | `SIGHTLINE_DEPLOY_TOKEN` | A deploy token (`slxd_…`) a workspace admin issued for your extension ids and actions, or an admin's own session token. A deploy token goes to the workspace's deploy routes (`/functions/v1/core/v1/deploy/…`), the only place it works. It is sent to that workspace only, never accepted as a flag, never in the URL. |
60
82
  | `SIGHTLINE_API_KEY` | The gateway's public API key, when the workspace needs one (sent as `apikey`). |
61
83
 
62
- `bindings.json` maps each permission your manifest declares to a Sightline permission the deploying admin holds: `{ "my-notes.read": "employees", "my-notes.write": "employees" }`. A permission left out gets the manifest's proposed `default` when the admin holds it.
84
+ Access control is the manifest's `rbac`: its resources, the permissions your tools name, and default access lines (`grants`). A deploy token never approves access: when the release has default access lines nobody decided yet, `deploy` and `upgrade` stop with `consent_required` and list them; an Administrator approves or declines them in Setup → Extensions (or installs from there), then run it again. `--bindings` (permission bindings) was removed in 0.1.6 and is refused.
63
85
 
64
86
  The deploy publishes the server bundle and migrations. Publishing a UI (`ui.frames`) to the workspace's extension-UI host is not part of the release API yet.
65
87
 
package/dist/args.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Argument parsing for sightline-ext: positionals, `--flag value`, `--flag=value`
2
2
  // and bare `--flag` (true). No dependencies, so it runs under Node and Deno alike.
3
3
  /** Flags that never take a value, so `--purge my-ext` keeps `my-ext` positional. */
4
- const BOOLEAN_FLAGS = new Set(["purge", "yes", "force", "no-db", "no-build", "help", "json", "ui", "watch"]);
4
+ const BOOLEAN_FLAGS = new Set(["purge", "yes", "force", "no-db", "no-build", "no-wait", "keep-db", "help", "json", "ui", "watch"]);
5
5
  export function parseArgs(argv) {
6
6
  const positional = [];
7
7
  const flags = {};
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const USAGE = "sightline-ext <command> [options]\n\n init <dir> Scaffold a new extension repository\n [--id <kebab-id>] [--name \"Display name\"] [--publisher <kebab-id>] [--force]\n build Validate the manifest and migrations, bundle server/main.ts into dist/server.mjs\n dev Build, then run the extension against an emulated Sightline, rebuilding on change\n [--port <n>] [--core-port 8787] [--no-db]\n test Build, run the extension against the emulator, and check the HTTP contract\n [--no-build] [--no-db] [--keep-db]\n deploy Build, publish the release and install it in a workspace\n upgrade Build, publish the release and move the installed extension to it\n (deploy, upgrade: [--bindings bindings.json] [--no-build])\n uninstall Remove the extension from the workspace (its data is kept 30 days)\n [--purge --yes]\n webhooks send <name> --body <file>\n Sign the file's raw bytes the way Sightline verifies them and POST them: to the\n running `dev` (verified and forwarded to your /hooks/<name>), or with\n --url <webhook URL from Setup> to a workspace. The secret is read from\n SL_SECRET_<NAME> (or --secret-env VAR, or devSecrets), never from a flag.\n [--url <url>] [--secret-env VAR] [--content-type application/json] [--timestamp <unix s>]\n\n Common: [--dir <project>] [--manifest sightline.extension.json] [--config sightline-ext.json]\n deploy, upgrade, uninstall: [--url https://<workspace>] [--api-url <core API base>]\n\nDeploying needs the workspace and a deploy token issued by one of its admins:\n SIGHTLINE_URL https://<your workspace> (or SIGHTLINE_API_URL, the full core API base)\n SIGHTLINE_DEPLOY_TOKEN the token (only ever read from the environment)\n SIGHTLINE_API_KEY the gateway's public api key, when your workspace needs one";
1
+ export declare const USAGE = "sightline-ext <command> [options]\n\n init <dir> Scaffold a new extension repository\n [--id <kebab-id>] [--name \"Display name\"] [--publisher <kebab-id>] [--force]\n build Validate the manifest and migrations, bundle server/main.ts into dist/server.mjs\n dev Build, then run the extension against an emulated Sightline, rebuilding on change\n [--port <n>] [--core-port 8787] [--no-db]\n test Build, run the extension against the emulator, and check the HTTP contract\n [--no-build] [--no-db] [--keep-db]\n deploy Build, publish the release and install it in a workspace\n upgrade Build, publish the release and move the installed extension to it\n (deploy, upgrade: [--no-build]; new default access waits for an admin's approval)\n uninstall Remove the extension from the workspace (its data is kept 30 days)\n [--purge --yes]\n yank <version> Withdraw a published release: it can no longer be installed, upgraded or rolled\n back to (an installation running it keeps running, flagged). No un-yank.\n [--reason \"\u2026\"]\n run <job> Start a run of a job now, outside its schedule, wait for it and print each call\n and the result; exit 1 unless it succeeded. [--no-wait] [--timeout <seconds, default 900>]\n webhooks send <name> --body <file>\n Sign the file's raw bytes the way Sightline verifies them and POST them: to the\n running `dev` (verified and forwarded to your /hooks/<name>), or with\n --url <webhook URL from Setup> to a workspace. The secret is read from\n SL_SECRET_<NAME> (or --secret-env VAR, or devSecrets), never from a flag.\n [--url <url>] [--secret-env VAR] [--content-type application/json] [--timestamp <unix s>]\n\n Common: [--dir <project>] [--manifest sightline.extension.json] [--config sightline-ext.json]\n deploy, upgrade, uninstall, yank, run: [--url https://<workspace>] [--api-url <core API base>]\n yank, run: on the project's extension (the manifest's id), or --id <extension id> with no project.\n A deploy token needs the action: publish, install, upgrade, uninstall, yank or run_job.\n\nDeploying needs the workspace and a deploy token issued by one of its admins:\n SIGHTLINE_URL https://<your workspace> (or SIGHTLINE_API_URL, the full core API base)\n SIGHTLINE_DEPLOY_TOKEN the token (only ever read from the environment)\n SIGHTLINE_API_KEY the gateway's public api key, when your workspace needs one";
2
2
  export type Out = (line: string) => void;
3
3
  export declare function packageVersion(): string;
4
4
  export declare function main(argv: string[], out?: Out): Promise<number>;
package/dist/cli.js CHANGED
@@ -8,8 +8,9 @@ import { assertContract, formatReport, runContractTests } from "@sparelabs/sight
8
8
  import { bool, int, parseArgs, str } from "./args.js";
9
9
  import { build, bundleOptions } from "./build.js";
10
10
  import { CoreApiError, createCoreApi, describeError } from "./core-api.js";
11
- import { deploy, parseBindings, uninstall, upgrade } from "./deploy.js";
12
- import { defaultContract, loadProject } from "./project.js";
11
+ import { deploy, runJob, uninstall, upgrade, yank } from "./deploy.js";
12
+ import { contractConfig, emulatorConfig, secretEnv } from "./local-run.js";
13
+ import { loadProject } from "./project.js";
13
14
  import { templateFiles } from "./template.js";
14
15
  import { planWebhookSend } from "./webhooks.js";
15
16
  export const USAGE = `sightline-ext <command> [options]
@@ -23,9 +24,14 @@ export const USAGE = `sightline-ext <command> [options]
23
24
  [--no-build] [--no-db] [--keep-db]
24
25
  deploy Build, publish the release and install it in a workspace
25
26
  upgrade Build, publish the release and move the installed extension to it
26
- (deploy, upgrade: [--bindings bindings.json] [--no-build])
27
+ (deploy, upgrade: [--no-build]; new default access waits for an admin's approval)
27
28
  uninstall Remove the extension from the workspace (its data is kept 30 days)
28
29
  [--purge --yes]
30
+ yank <version> Withdraw a published release: it can no longer be installed, upgraded or rolled
31
+ back to (an installation running it keeps running, flagged). No un-yank.
32
+ [--reason "…"]
33
+ run <job> Start a run of a job now, outside its schedule, wait for it and print each call
34
+ and the result; exit 1 unless it succeeded. [--no-wait] [--timeout <seconds, default 900>]
29
35
  webhooks send <name> --body <file>
30
36
  Sign the file's raw bytes the way Sightline verifies them and POST them: to the
31
37
  running \`dev\` (verified and forwarded to your /hooks/<name>), or with
@@ -34,7 +40,9 @@ export const USAGE = `sightline-ext <command> [options]
34
40
  [--url <url>] [--secret-env VAR] [--content-type application/json] [--timestamp <unix s>]
35
41
 
36
42
  Common: [--dir <project>] [--manifest sightline.extension.json] [--config sightline-ext.json]
37
- deploy, upgrade, uninstall: [--url https://<workspace>] [--api-url <core API base>]
43
+ deploy, upgrade, uninstall, yank, run: [--url https://<workspace>] [--api-url <core API base>]
44
+ yank, run: on the project's extension (the manifest's id), or --id <extension id> with no project.
45
+ A deploy token needs the action: publish, install, upgrade, uninstall, yank or run_job.
38
46
 
39
47
  Deploying needs the workspace and a deploy token issued by one of its admins:
40
48
  SIGHTLINE_URL https://<your workspace> (or SIGHTLINE_API_URL, the full core API base)
@@ -62,18 +70,6 @@ async function buildProject(p, out) {
62
70
  out(` warning: ${w}`);
63
71
  return result;
64
72
  }
65
- function secretEnv(p) {
66
- const env = {};
67
- for (const [k, v] of Object.entries(process.env))
68
- if (k.startsWith("SL_SECRET_") && v !== undefined)
69
- env[k] = v;
70
- for (const [name, value] of Object.entries(p.config.devSecrets ?? {})) {
71
- if (!/^[a-z][a-z0-9_-]{0,63}$/i.test(name) || typeof value !== "string")
72
- throw new Error(`devSecrets: "${name}" must be a secret name with a string value`);
73
- env[`SL_SECRET_${name.toUpperCase().replace(/-/g, "_")}`] = value;
74
- }
75
- return env;
76
- }
77
73
  const hasMigrations = (p) => existsSync(p.migrationsDir) && readdirSync(p.migrationsDir).some((f) => f.endsWith(".sql"));
78
74
  function waitForSignal() {
79
75
  return new Promise((done) => {
@@ -106,14 +102,9 @@ function init(positional, flags, out) {
106
102
  async function dev(flags, out) {
107
103
  const p = project(flags);
108
104
  await buildProject(p, out);
109
- const emulator = await createCoreEmulator({
110
- extension: p.id,
111
- version: p.version,
112
- settings: p.config.settings ?? {},
113
- // Its ext-hooks route verifies webhooks with these, as core does with the installation's secrets.
114
- manifest: p.manifest,
115
- secrets: secretEnv(p),
116
- });
105
+ // sightline-ext.json's users, workspace, people and coreScopes; the manifest (its webhooks, its
106
+ // required core scopes); devSecrets, which its ext-hooks route verifies with as core does.
107
+ const emulator = await createCoreEmulator(emulatorConfig(p, secretEnv(p, process.env)));
117
108
  const core = await listen(emulator.handler, { port: int(flags, "core-port") ?? 8787 });
118
109
  emulator.setOrigin(core.url);
119
110
  const env = emulator.runtimeEnv();
@@ -181,11 +172,12 @@ async function test(flags, out) {
181
172
  await buildProject(p, out);
182
173
  else if (!existsSync(p.outfile))
183
174
  throw new Error(`--no-build, but ${p.outfile} does not exist`);
184
- const contract = defaultContract(p);
185
- const emulator = await createCoreEmulator({ extension: p.id, version: p.version, settings: p.config.settings ?? {} });
175
+ // Read and checked before anything starts: a config mistake costs no container.
176
+ const contract = contractConfig(p);
177
+ const emulator = await createCoreEmulator(emulatorConfig(p, secretEnv(p, process.env)));
186
178
  const core = await listen(emulator.handler);
187
179
  emulator.setOrigin(core.url);
188
- const env = emulator.runtimeEnv(secretEnv(p));
180
+ const env = emulator.runtimeEnv();
189
181
  let db = null;
190
182
  let ext = null;
191
183
  try {
@@ -197,9 +189,7 @@ async function test(flags, out) {
197
189
  ext = await runExtension({ bundle: p.outfile, env, port: await freePort() });
198
190
  const report = await runContractTests(ext.url, {
199
191
  coreUrl: core.url,
200
- tool: contract.tool,
201
- job: contract.job,
202
- hook: contract.hook,
192
+ ...contract,
203
193
  expect: { version: p.version },
204
194
  });
205
195
  out(formatReport(report));
@@ -256,11 +246,18 @@ function coreApiFromEnv(flags, env = process.env) {
256
246
  const apiUrl = str(flags, "api-url") ?? (str(flags, "url") ? undefined : env.SIGHTLINE_API_URL);
257
247
  return createCoreApi({ url, apiUrl, token, apiKey: env.SIGHTLINE_API_KEY });
258
248
  }
259
- function readBindings(flags) {
260
- const file = str(flags, "bindings");
261
- if (!file)
262
- return undefined;
263
- return parseBindings(JSON.parse(readFileSync(file, "utf8")));
249
+ /** `--id <extension>`, else the project's manifest id. */
250
+ function extensionId(flags) {
251
+ const id = str(flags, "id") ?? project(flags).id;
252
+ if (!id)
253
+ throw new Error("name the extension: --id <extension id>, or run in its project (the manifest's id)");
254
+ return id;
255
+ }
256
+ /** `--bindings` was removed with permission bindings: say so instead of silently ignoring it. */
257
+ function refuseBindingsFlag(flags) {
258
+ if (flags.bindings !== undefined) {
259
+ throw new Error("--bindings was removed: declare `rbac` in the manifest; an Administrator approves its default access in Setup → Extensions");
260
+ }
264
261
  }
265
262
  async function release(flags, out) {
266
263
  const p = project(flags);
@@ -294,13 +291,15 @@ export async function main(argv, out = console.log) {
294
291
  case "test":
295
292
  return await test(flags, out);
296
293
  case "deploy": {
294
+ refuseBindingsFlag(flags);
297
295
  const api = coreApiFromEnv(flags);
298
- await deploy(api, await release(flags, out), { bindings: readBindings(flags) }, out);
296
+ await deploy(api, await release(flags, out), out);
299
297
  return 0;
300
298
  }
301
299
  case "upgrade": {
300
+ refuseBindingsFlag(flags);
302
301
  const api = coreApiFromEnv(flags);
303
- await upgrade(api, await release(flags, out), { bindings: readBindings(flags) }, out);
302
+ await upgrade(api, await release(flags, out), out);
304
303
  return 0;
305
304
  }
306
305
  case "uninstall": {
@@ -312,6 +311,27 @@ export async function main(argv, out = console.log) {
312
311
  await uninstall(api, id, { purge }, out);
313
312
  return 0;
314
313
  }
314
+ case "yank": {
315
+ const version = rest[0];
316
+ if (!version)
317
+ throw new Error('usage: sightline-ext yank <version> [--reason "…"]');
318
+ const api = coreApiFromEnv(flags);
319
+ await yank(api, extensionId(flags), version, { reason: str(flags, "reason") }, out);
320
+ return 0;
321
+ }
322
+ case "run": {
323
+ const job = rest[0];
324
+ if (!job)
325
+ throw new Error("usage: sightline-ext run <job> [--no-wait] [--timeout <seconds>]");
326
+ const timeout = str(flags, "timeout");
327
+ const seconds = timeout === undefined ? undefined : Number(timeout);
328
+ if (seconds !== undefined && (!Number.isInteger(seconds) || seconds < 1))
329
+ throw new Error("--timeout is a whole number of seconds");
330
+ const api = coreApiFromEnv(flags);
331
+ const wait = !bool(flags, "no-wait");
332
+ const run = await runJob(api, extensionId(flags), job, { wait, timeoutMs: seconds === undefined ? undefined : seconds * 1000 }, out);
333
+ return !wait || run.status === "succeeded" ? 0 : 1;
334
+ }
315
335
  case "webhooks":
316
336
  return await webhooks(rest, flags, out);
317
337
  case "version":
@@ -35,10 +35,39 @@ export interface InstallationView {
35
35
  installationId: string;
36
36
  state: string;
37
37
  version: string;
38
- bindings?: Record<string, string>;
39
38
  lastError?: string | null;
40
39
  [key: string]: unknown;
41
40
  }
41
+ /** One call of a job run (core's `runView`). */
42
+ export interface JobCallView {
43
+ id: string;
44
+ seq: number;
45
+ attempt: number;
46
+ trigger: string;
47
+ status: string;
48
+ startedAt?: string | null;
49
+ finishedAt?: string | null;
50
+ durationMs?: number | null;
51
+ httpStatus?: number | null;
52
+ error?: string | null;
53
+ retryAt?: string | null;
54
+ [key: string]: unknown;
55
+ }
56
+ /** A job run: 202 from run-now (`status: "running"`), or the run lookup with its calls. */
57
+ export interface JobRunView {
58
+ runId: string;
59
+ job: string;
60
+ version: string;
61
+ /** running, succeeded, failed, timed_out or abandoned. */
62
+ status: string;
63
+ trigger?: string;
64
+ startedAt?: string | null;
65
+ finishedAt?: string | null;
66
+ /** Between calls: when the next continuation or retry is due. */
67
+ resumeAt?: string | null;
68
+ calls?: JobCallView[];
69
+ [key: string]: unknown;
70
+ }
42
71
  export interface PublishInput {
43
72
  manifest: Record<string, unknown>;
44
73
  bundle: Uint8Array;
@@ -74,11 +103,9 @@ export interface CoreApi {
74
103
  }>;
75
104
  install(id: string, body: {
76
105
  version: string;
77
- bindings?: Record<string, string>;
78
106
  }): Promise<InstallationView>;
79
107
  upgrade(id: string, body: {
80
108
  version: string;
81
- bindings?: Record<string, string>;
82
109
  }): Promise<InstallationView>;
83
110
  uninstall(id: string, body: {
84
111
  purge?: boolean;
@@ -86,6 +113,17 @@ export interface CoreApi {
86
113
  installation: InstallationView;
87
114
  warnings: string[];
88
115
  }>;
116
+ /** Withdraw a published release (a deploy token needs the `yank` action). */
117
+ yank(id: string, version: string, body: {
118
+ reason?: string;
119
+ }): Promise<{
120
+ release: ReleaseView;
121
+ installed: boolean;
122
+ }>;
123
+ /** Start a run of a job now (a deploy token needs `run_job`): 202 `{ run }`. */
124
+ runJob(id: string, job: string): Promise<JobRunView>;
125
+ /** A run's state and calls (`run_job`). */
126
+ jobRun(id: string, job: string, runId: string): Promise<JobRunView>;
89
127
  }
90
128
  /** A deploy token an admin issued (`slxd_…`), as opposed to an admin's own session token. */
91
129
  export declare function isDeployToken(token: string): boolean;
package/dist/core-api.js CHANGED
@@ -3,9 +3,12 @@
3
3
  //
4
4
  // GET /core/v1/extensions { installations, releases }
5
5
  // POST /core/v1/extensions/releases { manifest, bundle: { base64, sha256 }, migrations? }
6
- // POST /core/v1/extensions/:id/install { version, bindings? }
7
- // POST /core/v1/extensions/:id/upgrade { version, bindings? }
6
+ // POST /core/v1/extensions/:id/install { version }
7
+ // POST /core/v1/extensions/:id/upgrade { version }
8
8
  // POST /core/v1/extensions/:id/uninstall { purge? }
9
+ // POST /core/v1/extensions/:id/releases/:version/yank { reason? } → { release, installed }
10
+ // POST /core/v1/extensions/:id/jobs/:name/run 202 { run }
11
+ // GET /core/v1/extensions/:id/jobs/:name/runs/:runId { run } (status and calls)
9
12
  //
10
13
  // Auth is a bearer token from SIGHTLINE_DEPLOY_TOKEN: a deploy token a tenant
11
14
  // admin issued (`slxd_…`, sent to the same routes under /core/v1/deploy, the
@@ -59,16 +62,16 @@ export function describeError(err) {
59
62
  switch (err.code) {
60
63
  case "route_missing":
61
64
  return err.message;
62
- case "consent_required":
63
- case "binding_required": {
64
- const missing = (d.missing ?? d.permissions ?? []);
65
- const names = missing.map((p) => (typeof p === "string" ? p : `${p.permission ?? p.key}${p.proposed ? ` (proposed: ${p.proposed})` : ""}`));
66
- return `${err.message}${names.length ? `\n bind: ${names.join(", ")}` : ""}\n pass --bindings bindings.json ({ "<permission>": "<core permission you hold>" })`;
65
+ case "consent_required": {
66
+ const pending = (d.ledger?.pending ?? []).map((l) => l.id).filter(Boolean);
67
+ return `${err.message}${pending.length ? `\n pending: ${pending.join(", ")}` : ""}\n an Administrator approves it in Setup → Extensions, then run this again`;
67
68
  }
68
69
  case "invalid_manifest": {
69
70
  const errors = (d.errors ?? []);
70
71
  return `${err.message}${errors.map((e) => `\n ${e.path ?? ""}: ${e.message ?? ""}`).join("")}`;
71
72
  }
73
+ case "job_running":
74
+ return `${err.message}${typeof d.runId === "string" ? `\n the run in progress: ${d.runId}` : ""}`;
72
75
  case "bundle_rejected":
73
76
  case "ui_rejected": {
74
77
  const errors = (d.errors ?? []);
@@ -162,5 +165,15 @@ export function createCoreApi(options) {
162
165
  const r = await call("POST", `/extensions/${encodeURIComponent(id)}/uninstall`, body);
163
166
  return { installation: r.installation, warnings: r.warnings ?? [] };
164
167
  },
168
+ async yank(id, version, body) {
169
+ const r = await call("POST", `/extensions/${encodeURIComponent(id)}/releases/${encodeURIComponent(version)}/yank`, body);
170
+ return { release: r.release, installed: r.installed === true };
171
+ },
172
+ async runJob(id, job) {
173
+ return (await call("POST", `/extensions/${encodeURIComponent(id)}/jobs/${encodeURIComponent(job)}/run`, {})).run;
174
+ },
175
+ async jobRun(id, job, runId) {
176
+ return (await call("GET", `/extensions/${encodeURIComponent(id)}/jobs/${encodeURIComponent(job)}/runs/${encodeURIComponent(runId)}`)).run;
177
+ },
165
178
  };
166
179
  }
package/dist/deploy.d.ts CHANGED
@@ -1,13 +1,37 @@
1
- import type { CoreApi, InstallationView, PublishInput } from "./core-api.ts";
1
+ import { type CoreApi, type InstallationView, type JobRunView, type PublishInput, type ReleaseView } from "./core-api.ts";
2
2
  export type Out = (line: string) => void;
3
- export declare function deploy(api: CoreApi, release: PublishInput, options: {
4
- bindings?: Record<string, string>;
5
- }, out: Out): Promise<InstallationView>;
6
- export declare function upgrade(api: CoreApi, release: PublishInput, options: {
7
- bindings?: Record<string, string>;
8
- }, out: Out): Promise<InstallationView>;
3
+ export declare function deploy(api: CoreApi, release: PublishInput, out: Out): Promise<InstallationView>;
4
+ export declare function upgrade(api: CoreApi, release: PublishInput, out: Out): Promise<InstallationView>;
9
5
  export declare function uninstall(api: CoreApi, id: string, options: {
10
6
  purge: boolean;
11
7
  }, out: Out): Promise<InstallationView>;
12
- /** --bindings <file>: { "<ext>.<permission>": "<core permission>" }. */
13
- export declare function parseBindings(raw: unknown): Record<string, string>;
8
+ /** Core's cap on a yank reason. */
9
+ export declare const MAX_YANK_REASON = 500;
10
+ /**
11
+ * yank <version>: withdraw a published release. It can no longer be installed,
12
+ * upgraded or rolled back to; an installation running it keeps running, flagged,
13
+ * until it is upgraded. Idempotent (the first yank stands); there is no un-yank.
14
+ */
15
+ export declare function yank(api: CoreApi, id: string, version: string, options: {
16
+ reason?: string;
17
+ }, out: Out): Promise<{
18
+ release: ReleaseView;
19
+ installed: boolean;
20
+ }>;
21
+ export interface RunJobOptions {
22
+ /** false: start the run and return at once. */
23
+ wait?: boolean;
24
+ /** Between status reads. Default 2 s. */
25
+ pollMs?: number;
26
+ /** Give up waiting (the run goes on in the workspace). Default 15 min. */
27
+ timeoutMs?: number;
28
+ sleep?: (ms: number) => Promise<void>;
29
+ now?: () => number;
30
+ }
31
+ /**
32
+ * run <job>: start a run of a job now, outside its schedule, then read its
33
+ * status until it finishes (a job that continues makes several calls under one
34
+ * run id) and print each call and the result. The run happens in the
35
+ * workspace: giving up waiting does not stop it.
36
+ */
37
+ export declare function runJob(api: CoreApi, id: string, job: string, options: RunJobOptions, out: Out): Promise<JobRunView>;
package/dist/deploy.js CHANGED
@@ -1,3 +1,9 @@
1
+ // deploy / upgrade / uninstall: the release lifecycle against a workspace's
2
+ // installer API. A deploy publishes the built release (idempotent for identical
3
+ // bytes) and installs it; an upgrade publishes and moves an existing
4
+ // installation to it; an uninstall removes it (data kept 30 days unless --purge).
5
+ // A yank withdraws a published release; a run starts a job now and waits for it.
6
+ import { CoreApiError } from "./core-api.js";
1
7
  /** States in which an extension counts as installed (core keeps the row after uninstall). */
2
8
  const LIVE = new Set(["enabled", "disabled", "provisioning"]);
3
9
  async function current(api, id) {
@@ -17,7 +23,7 @@ async function publish(api, release, out) {
17
23
  if (published.coreApiCompatible === false)
18
24
  out(` warning: this workspace's core does not satisfy coreApi ${String(release.manifest.coreApi)}; install will be refused`);
19
25
  }
20
- export async function deploy(api, release, options, out) {
26
+ export async function deploy(api, release, out) {
21
27
  const id = release.manifest.id;
22
28
  const version = release.manifest.version;
23
29
  const existing = await current(api, id);
@@ -29,11 +35,11 @@ export async function deploy(api, release, options, out) {
29
35
  out(`${id}@${version} is already installed (${existing.state}); nothing to do`);
30
36
  return existing;
31
37
  }
32
- const installation = await api.install(id, { version, ...(options.bindings ? { bindings: options.bindings } : {}) });
38
+ const installation = await api.install(id, { version });
33
39
  out(`installed ${id}@${installation.version}: ${installation.state} (installation ${installation.installationId})`);
34
40
  return installation;
35
41
  }
36
- export async function upgrade(api, release, options, out) {
42
+ export async function upgrade(api, release, out) {
37
43
  const id = release.manifest.id;
38
44
  const version = release.manifest.version;
39
45
  const existing = await current(api, id);
@@ -44,7 +50,7 @@ export async function upgrade(api, release, options, out) {
44
50
  out(`${id} already runs ${version}; nothing to upgrade (bump "version" in the manifest to release a change)`);
45
51
  return existing;
46
52
  }
47
- const installation = await api.upgrade(id, { version, ...(options.bindings ? { bindings: options.bindings } : {}) });
53
+ const installation = await api.upgrade(id, { version });
48
54
  out(`upgraded ${id} ${existing.version} → ${installation.version}: ${installation.state}`);
49
55
  return installation;
50
56
  }
@@ -57,15 +63,76 @@ export async function uninstall(api, id, options, out) {
57
63
  out(` warning: ${w}`);
58
64
  return installation;
59
65
  }
60
- /** --bindings <file>: { "<ext>.<permission>": "<core permission>" }. */
61
- export function parseBindings(raw) {
62
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
63
- throw new Error("bindings must be a JSON object of permission → core permission");
64
- const out = {};
65
- for (const [k, v] of Object.entries(raw)) {
66
- if (typeof v !== "string" || v === "")
67
- throw new Error(`the binding for ${k} must be a core permission name`);
68
- out[k] = v;
66
+ const RELEASE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
67
+ /** Core's cap on a yank reason. */
68
+ export const MAX_YANK_REASON = 500;
69
+ /**
70
+ * yank <version>: withdraw a published release. It can no longer be installed,
71
+ * upgraded or rolled back to; an installation running it keeps running, flagged,
72
+ * until it is upgraded. Idempotent (the first yank stands); there is no un-yank.
73
+ */
74
+ export async function yank(api, id, version, options, out) {
75
+ if (!RELEASE_VERSION_RE.test(version))
76
+ throw new Error(`usage: sightline-ext yank <version> [--reason "…"]; "${version}" is not MAJOR.MINOR.PATCH`);
77
+ const reason = options.reason?.trim();
78
+ if (reason !== undefined && reason.length > MAX_YANK_REASON)
79
+ throw new Error(`--reason is at most ${MAX_YANK_REASON} characters`);
80
+ const result = await api.yank(id, version, reason ? { reason } : {});
81
+ out(`yanked ${id}@${version}${reason ? ` (${reason})` : ""}: it can no longer be installed, upgraded or rolled back to`);
82
+ if (result.installed)
83
+ out(` warning: the workspace runs ${id}@${version} now; it keeps running, flagged, until you \`sightline-ext upgrade\` to a release that is not yanked`);
84
+ return result;
85
+ }
86
+ /** Statuses a run ends in (core's run lookup). */
87
+ const FINISHED = new Set(["succeeded", "failed", "timed_out", "abandoned"]);
88
+ function describeCall(c) {
89
+ const parts = [`call ${c.seq}`, c.trigger, c.status];
90
+ if (typeof c.httpStatus === "number")
91
+ parts.push(`HTTP ${c.httpStatus}`);
92
+ if (typeof c.durationMs === "number")
93
+ parts.push(`${c.durationMs} ms`);
94
+ return ` ${parts.join(", ")}${c.error ? `: ${c.error}` : ""}${c.retryAt ? ` (retry at ${c.retryAt})` : ""}`;
95
+ }
96
+ /**
97
+ * run <job>: start a run of a job now, outside its schedule, then read its
98
+ * status until it finishes (a job that continues makes several calls under one
99
+ * run id) and print each call and the result. The run happens in the
100
+ * workspace: giving up waiting does not stop it.
101
+ */
102
+ export async function runJob(api, id, job, options, out) {
103
+ if (!job)
104
+ throw new Error("usage: sightline-ext run <job>");
105
+ const started = await api.runJob(id, job);
106
+ out(`started ${id} job ${job} (run ${started.runId}, version ${started.version})`);
107
+ if (options.wait === false)
108
+ return started;
109
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
110
+ const now = options.now ?? Date.now;
111
+ const timeoutMs = options.timeoutMs ?? 15 * 60_000;
112
+ const deadline = now() + timeoutMs;
113
+ let printed = 0;
114
+ let run = started;
115
+ for (;;) {
116
+ await sleep(options.pollMs ?? 2_000);
117
+ try {
118
+ run = await api.jobRun(id, job, started.runId);
119
+ }
120
+ catch (e) {
121
+ // A run is claimed before its first call is readable everywhere: not found yet is not an answer.
122
+ if (!(e instanceof CoreApiError && e.code === "run_not_found"))
123
+ throw e;
124
+ }
125
+ const done = (run.calls ?? []).filter((c) => c.status !== "running");
126
+ for (const c of done.slice(printed))
127
+ out(describeCall(c));
128
+ printed = Math.max(printed, done.length);
129
+ if (FINISHED.has(run.status))
130
+ break;
131
+ if (now() >= deadline) {
132
+ out(`still ${run.status} after ${Math.round(timeoutMs / 1000)} s; the run goes on in the workspace (run ${started.runId})`);
133
+ return run;
134
+ }
69
135
  }
70
- return out;
136
+ out(`${job} ${run.status}${run.finishedAt ? ` at ${run.finishedAt}` : ""} (${(run.calls ?? []).length} call(s))`);
137
+ return run;
71
138
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { main, packageVersion, USAGE } from "./cli.ts";
2
2
  export { bool, type Flags, parseArgs, type ParsedArgs, str } from "./args.ts";
3
3
  export { build, type BuildDeps, type BuildResult, type Bundler, type BundleLinter, bundleOptions, type BundleOptions, collectUi, manifestProblems, type ManifestValidator, type UiValidator } from "./build.ts";
4
- export { apiBase, type CoreApi, CoreApiError, type CoreApiOptions, createCoreApi, describeError, type InstallationView, isDeployToken, lifecycleBase, type PublishInput, type ReleaseView, sha256Hex } from "./core-api.ts";
5
- export { deploy, parseBindings, uninstall, upgrade } from "./deploy.ts";
6
- export { CONFIG_FILE, type ContractConfig, defaultContract, hostedRuleProblems, loadProject, MANIFEST_FILE, migrationProblems, type MigrationFile, type Project, type ProjectConfig, readMigrations, readUiFiles, type UiFileOnDisk, uiFrames, } from "./project.ts";
4
+ export { apiBase, type CoreApi, CoreApiError, type CoreApiOptions, createCoreApi, describeError, type InstallationView, isDeployToken, type JobCallView, type JobRunView, lifecycleBase, type PublishInput, type ReleaseView, sha256Hex } from "./core-api.ts";
5
+ export { deploy, MAX_YANK_REASON, runJob, type RunJobOptions, uninstall, upgrade, yank } from "./deploy.ts";
6
+ export { contractConfig, type ContractRunConfig, type EmulatorConfig, emulatorConfig, secretEnv } from "./local-run.ts";
7
+ export { CONFIG_FILE, type ContractConfig, defaultContract, hostedRuleProblems, loadProject, MANIFEST_FILE, migrationProblems, type MigrationFile, type PermissionCheckConfig, type PersonConfig, type Project, type ProjectConfig, readMigrations, readUiFiles, type UiFileOnDisk, uiFrames, type UserConfig, type WorkspaceConfig, } from "./project.ts";
7
8
  export { lintBundle, MAX_BUNDLE_BYTES } from "@sparelabs/sightline-extension-manifest";
8
9
  export { templateFiles, type TemplateOptions, templateProblems, toolPrefix } from "./template.ts";
9
10
  export { planWebhookSend, secretVar, type WebhookSendInput, type WebhookSendPlan } from "./webhooks.ts";
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ export { main, packageVersion, USAGE } from "./cli.js";
5
5
  export { bool, parseArgs, str } from "./args.js";
6
6
  export { build, bundleOptions, collectUi, manifestProblems } from "./build.js";
7
7
  export { apiBase, CoreApiError, createCoreApi, describeError, isDeployToken, lifecycleBase, sha256Hex } from "./core-api.js";
8
- export { deploy, parseBindings, uninstall, upgrade } from "./deploy.js";
8
+ export { deploy, MAX_YANK_REASON, runJob, uninstall, upgrade, yank } from "./deploy.js";
9
+ export { contractConfig, emulatorConfig, secretEnv } from "./local-run.js";
9
10
  export { CONFIG_FILE, defaultContract, hostedRuleProblems, loadProject, MANIFEST_FILE, migrationProblems, readMigrations, readUiFiles, uiFrames, } from "./project.js";
10
11
  // The bundle lint is the published contract's, the same function core's installer runs.
11
12
  export { lintBundle, MAX_BUNDLE_BYTES } from "@sparelabs/sightline-extension-manifest";
@@ -0,0 +1,30 @@
1
+ import { type ContractConfig, type PermissionCheckConfig, type PersonConfig, type Project, type UserConfig, type WorkspaceConfig } from "./project.ts";
2
+ /** The devkit's `EmulatorOptions`, as far as the CLI fills them. */
3
+ export interface EmulatorConfig {
4
+ extension: string;
5
+ version: string;
6
+ settings: Record<string, unknown>;
7
+ manifest: Record<string, unknown>;
8
+ /** SL_SECRET_<NAME> → value: the runtime gets them, the ext-hooks route verifies with them. */
9
+ secrets: Record<string, string>;
10
+ users?: UserConfig[];
11
+ workspace?: WorkspaceConfig;
12
+ people?: PersonConfig[];
13
+ coreScopes?: string[];
14
+ }
15
+ /** The testing package's `ContractOptions`, minus the URLs. */
16
+ export interface ContractRunConfig {
17
+ tool: NonNullable<ContractConfig["tool"]>;
18
+ permissions?: PermissionCheckConfig[];
19
+ job?: ContractConfig["job"];
20
+ hook?: ContractConfig["hook"];
21
+ }
22
+ /**
23
+ * The extension's secrets as the runtime gets them: every SL_SECRET_* in the
24
+ * environment, then `devSecrets` on top (their names upper-cased, `-` → `_`).
25
+ */
26
+ export declare function secretEnv(p: Project, env: Record<string, string | undefined>): Record<string, string>;
27
+ /** The emulator `dev` and `test` start, from the project and its sightline-ext.json. */
28
+ export declare function emulatorConfig(p: Project, secrets: Record<string, string>): EmulatorConfig;
29
+ /** What `test` asks the contract kit to check: the tool (the manifest's first by default), permissions, job, hook. */
30
+ export declare function contractConfig(p: Project): ContractRunConfig;
@@ -0,0 +1,115 @@
1
+ // What `dev` and `test` hand the devkit's emulator and the contract kit, from
2
+ // the project and its sightline-ext.json: the manifest (its webhooks, its
3
+ // required core scopes), devSecrets, users, workspace, people, coreScopes, and
4
+ // the contract's tool, permissions, job and hook. Pure, so each field is tested
5
+ // without starting anything (test/cli.test.ts).
6
+ import { CONFIG_FILE, defaultContract } from "./project.js";
7
+ const SECRET_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
8
+ const where = (field) => `${CONFIG_FILE}: ${field}`;
9
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
10
+ const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
11
+ /**
12
+ * The extension's secrets as the runtime gets them: every SL_SECRET_* in the
13
+ * environment, then `devSecrets` on top (their names upper-cased, `-` → `_`).
14
+ */
15
+ export function secretEnv(p, env) {
16
+ const out = {};
17
+ for (const [k, v] of Object.entries(env))
18
+ if (k.startsWith("SL_SECRET_") && v !== undefined)
19
+ out[k] = v;
20
+ const dev = p.config.devSecrets ?? {};
21
+ if (!isObject(dev))
22
+ throw new Error(where("devSecrets must be an object of secret name → value"));
23
+ for (const [name, value] of Object.entries(dev)) {
24
+ if (!SECRET_NAME_RE.test(name) || typeof value !== "string")
25
+ throw new Error(`devSecrets: "${name}" must be a secret name with a string value`);
26
+ out[`SL_SECRET_${name.toUpperCase().replace(/-/g, "_")}`] = value;
27
+ }
28
+ return out;
29
+ }
30
+ function checkUsers(users) {
31
+ if (!Array.isArray(users))
32
+ throw new Error(where('users must be [{ "name": "alice", "email"?: "…", "grants"?: ["…"] }]'));
33
+ return users.map((u, i) => {
34
+ if (!isObject(u) || typeof u.name !== "string" || u.name === "")
35
+ throw new Error(where(`users[${i}] needs a "name"`));
36
+ if (u.email !== undefined && typeof u.email !== "string")
37
+ throw new Error(where(`users[${i}].email must be a string`));
38
+ if (u.grants !== undefined && !isStrings(u.grants))
39
+ throw new Error(where(`users[${i}].grants must be a list of permission keys`));
40
+ if (u.access !== undefined && !isObject(u.access))
41
+ throw new Error(where(`users[${i}].access must be { "<ext>.<resource>": [read, write] }`));
42
+ return u;
43
+ });
44
+ }
45
+ function checkWorkspace(workspace) {
46
+ if (!isObject(workspace))
47
+ throw new Error(where('workspace must be { "name"?, "timezone"?, "locale"?, "weekStart"? }'));
48
+ for (const k of ["name", "timezone", "locale"]) {
49
+ if (workspace[k] !== undefined && typeof workspace[k] !== "string")
50
+ throw new Error(where(`workspace.${k} must be a string`));
51
+ }
52
+ const ws = workspace.weekStart;
53
+ if (ws !== undefined && (typeof ws !== "number" || !Number.isInteger(ws) || ws < 1 || ws > 7))
54
+ throw new Error(where("workspace.weekStart is 1 (Monday) to 7 (Sunday)"));
55
+ return workspace;
56
+ }
57
+ function checkPeople(people) {
58
+ if (!Array.isArray(people))
59
+ throw new Error(where('people must be [{ "displayName": "…", "id"?, "title"?, "department"?, "avatarUrl"?, "active"? }]'));
60
+ people.forEach((p, i) => {
61
+ if (!isObject(p) || typeof p.displayName !== "string")
62
+ throw new Error(where(`people[${i}] needs a "displayName"`));
63
+ });
64
+ return people;
65
+ }
66
+ function checkPermissions(permissions) {
67
+ if (!Array.isArray(permissions))
68
+ throw new Error(where('contract.permissions must be [{ "tool": "<name>" | "api": "<METHOD> /path", "grants": ["…"] }]'));
69
+ return permissions.map((c, i) => {
70
+ const at = `contract.permissions[${i}]`;
71
+ if (!isObject(c))
72
+ throw new Error(where(`${at} must be an object`));
73
+ if ((typeof c.tool === "string") === (typeof c.api === "string"))
74
+ throw new Error(where(`${at} names exactly one of "tool" and "api"`));
75
+ if (typeof c.api === "string" && !/^[A-Z]+ \/\S*$/.test(c.api))
76
+ throw new Error(where(`${at}.api is "<METHOD> /path", e.g. "POST /kudos"`));
77
+ if (!isStrings(c.grants))
78
+ throw new Error(where(`${at}.grants must be a list of permission keys`));
79
+ if (c.as !== undefined && typeof c.as !== "string")
80
+ throw new Error(where(`${at}.as must be "user:<name>"`));
81
+ return c;
82
+ });
83
+ }
84
+ /** The emulator `dev` and `test` start, from the project and its sightline-ext.json. */
85
+ export function emulatorConfig(p, secrets) {
86
+ const c = p.config;
87
+ const out = { extension: p.id, version: p.version, settings: c.settings ?? {}, manifest: p.manifest, secrets };
88
+ if (c.users !== undefined)
89
+ out.users = checkUsers(c.users);
90
+ if (c.workspace !== undefined)
91
+ out.workspace = checkWorkspace(c.workspace);
92
+ if (c.people !== undefined)
93
+ out.people = checkPeople(c.people);
94
+ if (c.coreScopes !== undefined) {
95
+ if (!isStrings(c.coreScopes))
96
+ throw new Error(where('coreScopes must be a list of core scopes, e.g. ["core:people:read"]'));
97
+ out.coreScopes = c.coreScopes;
98
+ }
99
+ return out;
100
+ }
101
+ /** What `test` asks the contract kit to check: the tool (the manifest's first by default), permissions, job, hook. */
102
+ export function contractConfig(p) {
103
+ const contract = defaultContract(p);
104
+ const tool = contract.tool;
105
+ if (tool.grants !== undefined && !isStrings(tool.grants))
106
+ throw new Error(where("contract.tool.grants must be a list of permission keys"));
107
+ const out = { tool };
108
+ if (contract.permissions !== undefined)
109
+ out.permissions = checkPermissions(contract.permissions);
110
+ if (contract.job)
111
+ out.job = contract.job;
112
+ if (contract.hook)
113
+ out.hook = contract.hook;
114
+ return out;
115
+ }
package/dist/project.d.ts CHANGED
@@ -19,11 +19,23 @@ export declare const MAX_MIGRATION_BYTES: number;
19
19
  export declare const MAX_MIGRATIONS = 200;
20
20
  export declare const EXT_ID_RE: RegExp;
21
21
  export declare const MIGRATION_FILE_RE: RegExp;
22
+ /** A permission-checked tool or /api route `test` calls with and without its grants (the testing package's `PermissionCheck`). */
23
+ export interface PermissionCheckConfig {
24
+ /** A tool name. Name exactly one of `tool` and `api`. */
25
+ tool?: string;
26
+ /** An /api route as `"<METHOD> /path"`, e.g. `"POST /kudos"`. */
27
+ api?: string;
28
+ input?: unknown;
29
+ as?: string;
30
+ grants: string[];
31
+ }
22
32
  export interface ContractConfig {
33
+ /** `grants`: what `as` holds for these calls; absent, what `users` gives them. */
23
34
  tool?: {
24
35
  name: string;
25
36
  input?: Record<string, unknown>;
26
37
  as?: string;
38
+ grants?: string[];
27
39
  };
28
40
  job?: {
29
41
  name: string;
@@ -33,17 +45,53 @@ export interface ContractConfig {
33
45
  name: string;
34
46
  body?: unknown;
35
47
  };
48
+ /** Each must succeed with its grants and be refused (permission_denied / scope_missing / 403) with none. */
49
+ permissions?: PermissionCheckConfig[];
50
+ }
51
+ /** An emulated person who can call the extension (`--as user:<name>`): the devkit's `DevUser`. */
52
+ export interface UserConfig {
53
+ name: string;
54
+ email?: string;
55
+ /** The permission keys they hold: their tokens' scopes. */
56
+ grants?: string[];
57
+ /** Their bits on the extension's own resources (`{ "<ext>.<resource>": [read, write] }`): the token's `access`. */
58
+ access?: Record<string, [number, number]>;
59
+ }
60
+ /** A person in the emulated people directory (/v1/people): the devkit's `DevPerson`. */
61
+ export interface PersonConfig {
62
+ id?: string;
63
+ displayName: string;
64
+ avatarUrl?: string | null;
65
+ title?: string | null;
66
+ department?: string | null;
67
+ active?: boolean;
68
+ }
69
+ /** The workspace /v1/me reports (default: "Dev workspace", UTC, en-US, Sunday). */
70
+ export interface WorkspaceConfig {
71
+ name?: string;
72
+ timezone?: string;
73
+ locale?: string;
74
+ /** ISO: 1 = Monday … 7 = Sunday. */
75
+ weekStart?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
36
76
  }
37
77
  /** sightline-ext.json: how `dev` and `test` run the extension locally. Never deployed. */
38
78
  export interface ProjectConfig {
39
79
  /** The server entry (default server/main.ts). */
40
80
  entry?: string;
41
- /** What `test` checks: a tool that succeeds, and optionally a job and a hook. */
81
+ /** What `test` checks: a tool that succeeds, permission checks, and optionally a job and a hook. */
42
82
  contract?: ContractConfig;
43
83
  /** The installation settings the emulated core answers with. */
44
84
  settings?: Record<string, unknown>;
45
- /** Development-only secret values, injected as SL_SECRET_<NAME>. Never deployed. */
85
+ /** Development-only secret values, injected as SL_SECRET_<NAME> (and verified with by the emulator's ext-hooks). Never deployed. */
46
86
  devSecrets?: Record<string, string>;
87
+ /** The emulated people and their grants (default: alice and bob, holding nothing). */
88
+ users?: UserConfig[];
89
+ /** The workspace the emulated core reports. */
90
+ workspace?: WorkspaceConfig;
91
+ /** The emulated people directory (default: one person per user). */
92
+ people?: PersonConfig[];
93
+ /** The core scopes the installation holds (default: the manifest's `scopes.required`, as installing grants them). */
94
+ coreScopes?: string[];
47
95
  /** The built UI `deploy` uploads with the release, for ui.frames (default dist/ui; build it with your own tool first). */
48
96
  ui?: {
49
97
  dir?: string;
package/dist/template.js CHANGED
@@ -34,10 +34,16 @@ export function templateFiles(options) {
34
34
  trust: "T2",
35
35
  coreApi: "^0.1",
36
36
  runtime: { kind: "hosted", entry: "dist/server.mjs", memoryMb: 256 },
37
- permissions: [
38
- { key: `${id}.read`, label: "Read notes", default: "employees" },
39
- { key: `${id}.write`, label: "Write notes", default: "employees" },
40
- ],
37
+ // Access control: one resource, the permissions the tools name, and the
38
+ // default access a workspace admin approves on install (Setup → Extensions).
39
+ rbac: {
40
+ resources: [{ key: `${id}.notes`, label: "Notes", description: "Notes your team keeps.", read: ["self", "reports", "others"], write: ["self"] }],
41
+ permissions: [
42
+ { key: `${id}.read`, label: "Read notes", requires: [`${id}.notes`] },
43
+ { key: `${id}.write`, label: "Write notes", requires: [`${id}.notes`], requireWrite: true },
44
+ ],
45
+ grants: [{ role: "employee", resource: `${id}.notes`, read: ["others"], write: ["self"] }],
46
+ },
41
47
  tools: [
42
48
  {
43
49
  name: `${p}_list`,
@@ -203,7 +209,7 @@ SIGHTLINE_URL=https://<your workspace> SIGHTLINE_DEPLOY_TOKEN=… npx sightline-
203
209
 
204
210
  | Path | What |
205
211
  | --- | --- |
206
- | \`sightline.extension.json\` | The manifest: id, version, permissions, tools |
212
+ | \`sightline.extension.json\` | The manifest: id, version, access control (\`rbac\`), tools |
207
213
  | \`server/main.ts\` | The backend (\`@sparelabs/sightline-extension-sdk\`) |
208
214
  | \`migrations/NNNN_<name>.sql\` | Your database, in order; only ever add files |
209
215
  | \`sightline-ext.json\` | Local only: what \`test\` calls, dev settings and secrets |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparelabs/sightline-extension-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "sightline-ext: scaffold, run, build, test and deploy a hosted Sightline extension.",
5
5
  "license": "MIT",
6
6
  "author": "Spare Labs Inc.",
@@ -34,9 +34,9 @@
34
34
  "typecheck": "tsc -p tsconfig.json"
35
35
  },
36
36
  "dependencies": {
37
- "@sparelabs/sightline-extension-devkit": "0.1.3",
38
- "@sparelabs/sightline-extension-manifest": "0.1.2",
39
- "@sparelabs/sightline-extension-testing": "0.1.0",
37
+ "@sparelabs/sightline-extension-devkit": "0.1.5",
38
+ "@sparelabs/sightline-extension-manifest": "0.1.3",
39
+ "@sparelabs/sightline-extension-testing": "0.1.1",
40
40
  "esbuild": "^0.25.10"
41
41
  },
42
42
  "devDependencies": {