@astrale-domains/services 0.5.1 → 0.6.0

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.
Files changed (70) hide show
  1. package/README.md +149 -51
  2. package/dist/scaffold/create-service.mjs +94 -0
  3. package/dist/scaffold/template/.env.example +4 -0
  4. package/dist/scaffold/template/README.md +75 -0
  5. package/dist/scaffold/template/gitignore +6 -0
  6. package/dist/scaffold/template/scripts/build.mjs +20 -0
  7. package/dist/scaffold/template/scripts/deploy.mjs +65 -0
  8. package/dist/scaffold/template/scripts/service.mjs +69 -0
  9. package/dist/scaffold/template/src/account.js +31 -0
  10. package/dist/scaffold/template/src/index.js +50 -0
  11. package/dist/scaffold/template/src/job.js +22 -0
  12. package/dist/schema/index.d.ts +2 -2
  13. package/dist/schema/index.js +1 -1
  14. package/dist/schema/modules/deployment/classes/cloudflare-deployment.d.ts +41 -20
  15. package/dist/schema/modules/deployment/classes/deployment.d.ts +54 -25
  16. package/dist/schema/modules/deployment/classes/deployment.js +20 -9
  17. package/dist/schema/modules/deployment/functions/retire-deployment.d.ts +5 -5
  18. package/dist/schema/modules/deployment/index.d.ts +2 -2
  19. package/dist/schema/modules/deployment/index.js +1 -1
  20. package/dist/schema/modules/deployment/types/deployment-hosting.d.ts +19 -49
  21. package/dist/schema/modules/deployment/types/deployment-hosting.js +10 -10
  22. package/dist/schema/modules/deployment/types/deployment-request.d.ts +45 -93
  23. package/dist/schema/modules/deployment/types/deployment-request.js +60 -30
  24. package/dist/schema/modules/deployment/types/deployment-response.d.ts +14 -22
  25. package/dist/schema/modules/deployment/types/deployment-response.js +5 -2
  26. package/dist/schema/modules/deployment/types/deployment-secret.d.ts +2 -2
  27. package/dist/schema/modules/deployment/types/deployment-secret.js +2 -2
  28. package/dist/schema/modules/deployment/types/deployment-summary.d.ts +13 -112
  29. package/dist/schema/modules/deployment/types/deployment-summary.js +16 -18
  30. package/dist/schema/modules/deployment/types/index.d.ts +10 -12
  31. package/dist/schema/modules/deployment/types/index.js +5 -6
  32. package/dist/schema/modules/deployment/types/{frozen-configuration.d.ts → legacy-deployment.d.ts} +24 -11
  33. package/dist/schema/modules/deployment/types/{frozen-configuration.js → legacy-deployment.js} +34 -10
  34. package/dist/schema/modules/deployment/types/legacy-migration.d.ts +17 -0
  35. package/dist/schema/modules/deployment/types/legacy-migration.js +17 -0
  36. package/dist/schema/modules/deployment/types/worker-configuration.d.ts +21 -0
  37. package/dist/schema/modules/deployment/types/worker-configuration.js +21 -0
  38. package/dist/schema/modules/deployment/types/worker-record.d.ts +12 -0
  39. package/dist/schema/modules/deployment/types/worker-record.js +15 -0
  40. package/dist/schema/modules/service/classes/cloudflare-service.d.ts +72 -135
  41. package/dist/schema/modules/service/classes/cloudflare-service.js +6 -1
  42. package/dist/schema/modules/service/classes/service.d.ts +17 -7
  43. package/dist/schema/modules/service/classes/service.js +14 -8
  44. package/dist/schema/modules/service/functions/deploy-service.d.ts +44 -100
  45. package/dist/schema/modules/service/functions/deploy-service.js +6 -5
  46. package/dist/schema/modules/service/functions/describe-hosting.d.ts +0 -1
  47. package/dist/schema/modules/service/functions/index.d.ts +1 -0
  48. package/dist/schema/modules/service/functions/index.js +1 -0
  49. package/dist/schema/modules/service/functions/list-service-deployments.d.ts +12 -36
  50. package/dist/schema/modules/service/functions/migrate-legacy-deployments.d.ts +17 -0
  51. package/dist/schema/modules/service/functions/migrate-legacy-deployments.js +15 -0
  52. package/dist/schema/modules/service/functions/promote-service.d.ts +2 -2
  53. package/dist/schema/modules/service/functions/set-service-secret.d.ts +2 -2
  54. package/dist/schema/modules/service/functions/transfer-service.d.ts +2 -2
  55. package/dist/schema/modules/service/index.d.ts +1 -1
  56. package/dist/schema/modules/service/index.js +1 -1
  57. package/dist/schema/schema.d.ts +158 -177
  58. package/package.json +11 -7
  59. package/dist/schema/modules/deployment/types/content/index.d.ts +0 -1
  60. package/dist/schema/modules/deployment/types/content/index.js +0 -1
  61. package/dist/schema/modules/deployment/types/content/limits.d.ts +0 -7
  62. package/dist/schema/modules/deployment/types/content/limits.js +0 -7
  63. package/dist/schema/modules/deployment/types/deployment-record.d.ts +0 -35
  64. package/dist/schema/modules/deployment/types/deployment-record.js +0 -31
  65. package/dist/schema/modules/deployment/types/service-deployment-artifact-module.d.ts +0 -26
  66. package/dist/schema/modules/deployment/types/service-deployment-artifact-module.js +0 -10
  67. package/dist/schema/modules/deployment/types/service-deployment-assets.d.ts +0 -49
  68. package/dist/schema/modules/deployment/types/service-deployment-assets.js +0 -16
  69. package/dist/schema/modules/deployment/types/service-deployment-content.d.ts +0 -19
  70. package/dist/schema/modules/deployment/types/service-deployment-content.js +0 -24
package/README.md CHANGED
@@ -1,25 +1,35 @@
1
1
  # Services
2
2
 
3
- `services.astrale.ai` hosts Services: provider-backed compute an Identity owns. A Service is the
4
- stable identity of one origin in one environment. Every deploy adds an immutable deployment of it,
5
- and the Service's URL serves the one it currently points at, like a project and its deployments.
3
+ `services.astrale.ai` hosts Services: Cloudflare Workers an Identity owns. A Service is the stable
4
+ identity of one project in one environment. Every deploy adds an immutable deployment of it, and
5
+ the Service's URL serves the one it currently points at, like a project and its deployments.
6
+
7
+ Services is agnostic of what it hosts. An Astrale Domain release (`astrale-domain deploy` with
8
+ `@astrale-os/adapter-astrale`) and a generic Worker (a cron job, an API, a
9
+ [`create-astrale-service`](#generic-services) project) are the same thing to it: a Worker build,
10
+ the configuration it freezes, the secrets it binds and, when it publishes one, an opaque record.
11
+ Every Domain fact (origin, release, Schema revision, Publication, issuer) is the deployer's.
6
12
  Provider protocols stay behind a neutral SDK Integration, and the graph keeps only admitted
7
13
  lifecycle evidence.
8
14
 
9
15
  ## Model
10
16
 
11
- - **`Service` / `CloudflareService`**: one `(origin, environment)`. Its `name` is the opaque
12
- 16-character fingerprint the SDK derives from them (`deploymentLine({ addressing: 'opaque' })`);
13
- it is unique among Services and is the DNS label of `url`, `https://<name>.<routing domain>`.
14
- `state` is `active`, or `deleting` while its deletion retires its deployments.
17
+ - **`Service` / `CloudflareService`**: one `(project, environment)`. A project is any name
18
+ (`PROJECT_NAME` of `@astrale-os/sdk/deployment/worker`); a Domain deployer names its Domain's
19
+ origin. Its `name` is the opaque 16-character `serviceName(project, environment)`, unique among
20
+ Services, the DNS label of `url`, `https://<name>.<routing domain>`. `state` is `active`, or
21
+ `deleting` while its deletion retires its deployments.
15
22
  - **`service_owned_by`**: every Service has exactly one owner, an Identity: a user, a group or a
16
23
  service account. It is chosen once, when the first deploy creates the Service, and changes only
17
24
  by `transfer`.
18
- - **`Deployment` / `CloudflareDeployment`**: one immutable release of a Service: one build with
19
- its frozen configuration, served by one Workers for Platforms script at its own URL,
20
- `https://<label>.<routing domain>`, where `label` is `<name>-<content16>`. It never changes code:
21
- a new release is a new deployment. Its lifecycle is `deploying` → `active` → `retiring` →
22
- `retired`, or `failed`; a retired deployment is never served again.
25
+ - **`Deployment` / `CloudflareDeployment`**: one immutable release of a Service, served by one
26
+ Workers for Platforms script at its own URL, `https://<label>.<routing domain>`, where `label` is
27
+ `<name>-<content16>` and the content covers the build digest and the configuration digest. It
28
+ keeps generic facts only: `buildDigest`, its `configuration` (`WorkerConfigurationV1`) and
29
+ `configurationDigest`, the `record` it publishes if any (opaque bytes and their media type), its
30
+ key id when it asked for a signing identity, the script's code hash, the keyed digests of its
31
+ secrets, and its lifecycle: `deploying` → `active` → `retiring` → `retired`, or `failed`. A
32
+ retired deployment is never served again.
23
33
  - **`deployment_of`**: the Service a deployment is a version of. A deployment has no owner of its
24
34
  own: it belongs to whoever owns its Service.
25
35
  - **`current_deployment`**: the deployment the Service's URL serves and its crons run on. Promoting
@@ -42,13 +52,13 @@ caller in three groups also reaches the Services of each.
42
52
  Service of the group, deletion included.
43
53
  - `OwnDeployment` gives a deployment to the owner of its Service.
44
54
 
45
- Every Method is `authenticated`: a deployer's identities on the Admin instance hold no `can_use` on
46
- Services' Functions, which an `authorized` Method requires of every non-Root caller. Each handler
47
- asks the Kernel `Identity.can` for the caller on its own session before it reads anything: a caller
48
- who does not own the Service finds none (`SERVICE_NOT_FOUND`, `DEPLOYMENT_NOT_FOUND`), and a deploy
49
- of another owner's Service is refused `service-owned-elsewhere`. The day the Admin instance grants
50
- deployers `can_use` on these Methods, they become `authorized` with the Policy and the handler
51
- checks go.
55
+ Every Method but `migrate` is `authenticated`: a deployer's identities on the Admin instance hold
56
+ no `can_use` on Services' Functions, which an `authorized` Method requires of every non-Root caller.
57
+ Each handler asks the Kernel `Identity.can` for the caller on its own session before it reads
58
+ anything: a caller who does not own the Service finds none (`SERVICE_NOT_FOUND`,
59
+ `DEPLOYMENT_NOT_FOUND`), and a deploy of another owner's Service is refused
60
+ `service-owned-elsewhere`. The day the Admin instance grants deployers `can_use` on these Methods,
61
+ they become `authorized` with the Policy and the handler checks go.
52
62
 
53
63
  `list` reads Services under the Services Domain credential and keeps those the Kernel answers
54
64
  `OwnService` for, in batches: a caller-mode read does not reach the Identity end of
@@ -57,39 +67,48 @@ checks go.
57
67
 
58
68
  ## Methods
59
69
 
60
- Call them on the Admin instance (`astrale call ... --admin`).
70
+ Call them on the Admin instance (`astrale call ... --admin`). Their wire contract is
71
+ `@astrale-os/sdk/deployment/worker`'s.
61
72
 
62
73
  | Method | On | What it does |
63
74
  | --- | --- | --- |
64
- | `deploy` | `CloudflareService` (static) | Hosts one release (`DeploymentRequestV1`) as a deployment of the Service of its origin and environment, creating the Service on its first deploy, and by default (`promote` absent or `true`) makes the Service serve it. |
65
- | `hosting` | `CloudflareService` (static) | The routing domain Services and deployments are addressed under; names are always opaque. |
66
- | `list` | `CloudflareService` (static) | The deployments of the Services the caller owns, narrowed to an origin and environment when given, each with `current`. |
75
+ | `deploy` | `CloudflareService` (static) | Hosts one Worker release (`WorkerDeploymentRequestV1`) as a deployment of the Service of its project and environment, creating the Service on its first deploy, and by default (`promote` absent or `true`) makes the Service serve it. Answers `WorkerDeploymentResponseV1`. |
76
+ | `hosting` | `CloudflareService` (static) | The routing domain Services and deployments are addressed under (`WorkerHostingV1`); names are always opaque. |
77
+ | `list` | `CloudflareService` (static) | The deployments of the Services the caller owns (`WorkerDeploymentSummaryV1`), narrowed to a `project` and an `environment` when given, each with `current` and the record it publishes. |
67
78
  | `promote` | `CloudflareService` | Points the Service at one of its active deployments, named by its label. |
68
79
  | `transfer` | `CloudflareService` | Gives the Service to another owner the caller is or acts as. |
69
80
  | `setSecret`, `secrets` | `CloudflareService` | Rewrites one declared secret of the current deployment in place; lists the names it binds, never a value. |
70
81
  | `setSchedule`, `schedules` | `CloudflareService` | Replaces or reads the Service's UTC cron set; each cron fires on the current deployment. |
71
82
  | `logs` | `CloudflareService` | Recent logs of the current deployment, or of one deployment named by `label`. |
72
83
  | `delete` | `CloudflareService` | Stops routing the Service, retires every deployment of it, then deletes it and frees its name. |
84
+ | `migrate` | `CloudflareService` (static, Root) | Once, after this revision is installed: rewrites the Services and deployments stored before Services hosted any Worker (see [Migration](#migration-to-the-agnostic-model)). |
73
85
  | `retire` | `CloudflareDeployment` | Withdraws one deployment for good. The current deployment is never retired alone. |
74
86
 
75
87
  ## Deploying
76
88
 
77
- `astrale-domain deploy <environment>` with the Astrale adapter calls `CloudflareService.deploy`.
78
- `deploy` is a non-durable Action: its input holds the environment's secrets, and no Execution, step
79
- result or journal entry keeps them.
80
-
81
- 1. Before any read it recomputes the label from the Service name, the build digest and the frozen
82
- configuration, recomputes the build digest from the uploaded bytes, admits the record with the
83
- SDK, and refuses a service binding, a dispatch namespace, a router other than the logical
84
- `admin-router`, a reserved binding name, or secrets other than exactly the declared ones.
85
- 2. It reads the Service of the fingerprint under the Services Domain credential and proves the
86
- caller's authority on the caller's own session: `OwnService` on an existing Service, `ActAs` on
87
- the owner it names for a new one.
88
- 3. A new label is created under a graph lease. The provider uploads it create-only with
89
- `ASTRALE_BUILD_DIGEST`, `ASTRALE_SETTINGS`, a signing key generated for this deployment alone,
90
- and the request's secrets; checks that the uploaded version serves its readiness paths; then
91
- activates it through the routing KV `record:<label>`. The graph records it active with its key
92
- id, script etag and keyed HMAC-SHA256 digests of its secrets.
89
+ `deploy` is a non-durable Action: its input holds the secrets, and no Execution, step result or
90
+ journal entry keeps them.
91
+
92
+ 1. Before any read, the SDK's `decodeWorkerDeploymentRequest` admits the request: every file
93
+ decoded within its bounds, the configuration canonical, a record exactly when the configuration
94
+ asks for one, the secrets exactly the names it declares. It recomputes the build digest from
95
+ the uploaded bytes (`build-mismatch`) and the label from the Service name, the build and the
96
+ configuration (`address-mismatch`); anything else is `invalid-deployment`. A configuration that
97
+ asks for the router where this environment has none is `hosting-unavailable`.
98
+ 2. It reads the Service of the name under the Services Domain credential and proves the caller's
99
+ authority on the caller's own session: `OwnService` on an existing Service, `ActAs` on the owner
100
+ it names for a new one.
101
+ 3. A new label is created under a graph lease. The provider uploads it create-only with the host
102
+ bindings (its URL as `WORKER_URL`, the recomputed `ASTRALE_BUILD_DIGEST`, the version
103
+ metadata `ASTRALE_VERSION_METADATA`), the configuration's variables, a signing key generated
104
+ for this deployment alone (`ASTRALE_SIGNING_IDENTITY`) only when it asks `signingIdentity`, the
105
+ router (`ROUTER`) only when it asks `router`, and the request's secrets. When the request names
106
+ readiness paths, each must answer 200 from the uploaded version (`x-astrale-worker-version`);
107
+ none skips the probe. The label is then activated through the routing KV `record:<label>` with
108
+ the record the deployment publishes, served byte for byte with its media type at
109
+ `/.well-known/astrale/deployment.json`, or a placeholder when it publishes none. The graph
110
+ records it active with its code hash, its key id when it has one, and keyed HMAC-SHA256 digests
111
+ of its secrets.
93
112
  4. The same release again is the same label: `deploy` answers `reused`. Its secrets are compared
94
113
  with the recorded digests: `unchanged`, or rewritten in place on that deployment alone,
95
114
  `updated`.
@@ -97,28 +116,49 @@ result or journal entry keeps them.
97
116
  served, and the routing KV `current:<name>` names its label. A routing write that failed is
98
117
  answered `service-unavailable`; deploying or promoting again converges.
99
118
 
100
- `WORKER_URL` and `ASTRALE_BUILD_DIGEST` are bound by the host, never by the author: Services refuses
101
- them as caller variables or secrets.
119
+ The names the host binds itself (`WORKER_HOST_BINDINGS`: `WORKER_URL`, `ASTRALE_BUILD_DIGEST`,
120
+ `ASTRALE_VERSION_METADATA`, `ASTRALE_SIGNING_IDENTITY`, `ROUTER`, `ASSETS`) are never a variable
121
+ or a secret of the request.
122
+
123
+ - **A Domain** is deployed by `astrale-domain deploy <environment>` with `astrale()` of
124
+ `@astrale-os/adapter-astrale`: its project is the Domain's origin, its build manifest members are
125
+ the Domain's (so `ASTRALE_BUILD_DIGEST` is the sealed release's), its execution settings travel
126
+ as the `ASTRALE_SETTINGS` variable, it publishes its `DeploymentRecordV1` and asks for a signing
127
+ identity, and readiness probes the release's documents. The adapter, not Services, verifies the
128
+ release served and installs it deliberately.
129
+ - **Any other Worker** is deployed with `@astrale-os/adapter-astrale/services`
130
+ (`readWorkerDirectory`, `deployWorker`, `callService`), as the projects
131
+ [`create-astrale-service`](#generic-services) generates do.
102
132
 
103
133
  ## Routing and crons
104
134
 
105
- The Services dispatcher serves one routing domain:
135
+ The Services dispatcher is the hosting kit's dispatcher (`@astrale-os/adapter-cloudflare/hosting`),
136
+ extended only with the frozen legacy tenant routing of `providers/cloudflare/legacy` (removal: D20).
137
+ It serves one routing domain:
106
138
 
107
139
  - `https://<label>.<routing domain>` serves that deployment: `record:<label>` activates it, and
108
- `retirement:<label>` answers `DEPLOYMENT_RETIRED` (`410`).
140
+ `retirement:<label>` answers `DEPLOYMENT_RETIRED` (`410`). It answers the deployment's record at
141
+ `/.well-known/astrale/deployment.json` itself; a deployment that publishes none answers that path.
109
142
  - `https://<name>.<routing domain>` serves the deployment `current:<name>` names, through the same
110
143
  path, so a retired deployment is never served through its Service either.
111
144
  - Every minute the dispatcher reads the `cron:<name>` keys and posts each due cron to the current
112
- deployment of that Service, at `https://cron.internal/__scheduled`.
145
+ deployment of that Service: `POST https://cron.internal/__scheduled` with the expression in
146
+ `x-astrale-cron` and the minute it fired for, in epoch milliseconds, in
147
+ `x-astrale-scheduled-time`, within a 10-second deadline. A non-2xx answer is logged. The public
148
+ never reaches `/__scheduled`: the dispatcher refuses it in every spelling (case, repeated or
149
+ trailing slashes, percent-encoding) before any lookup. A run is not exactly-once: a job must be
150
+ safe to repeat.
151
+ - A legacy tenant Worker with a `service:<name>` entry keeps its routing and crons ahead of all of
152
+ this, until D20 removes them.
113
153
 
114
154
  ## Secrets
115
155
 
116
156
  Services never keeps a value. A deploy binds the request's secrets to the new deployment only;
117
- every other deployment keeps its own. `setSecret` rewrites one name the current deployment's frozen
157
+ every other deployment keeps its own. `setSecret` rewrites one name the current deployment's
118
158
  configuration declares, in place, under the deployment's graph lease: it compares the keyed
119
159
  digests, writes only changed values, forgets the digests of the names it writes first and records
120
160
  the new ones once every write succeeded. The provider serves a new value a few seconds later. A
121
- new name, or removing one, is a new release; the signing key never rotates.
161
+ new name, or removing one, is a new release; a signing key never rotates.
122
162
 
123
163
  Never pass a secret value in argv. Write the input to a file only you can read, outside the
124
164
  repository:
@@ -139,7 +179,7 @@ carries.
139
179
 
140
180
  ```sh
141
181
  astrale call /:services.astrale.ai:class.CloudflareService:list \
142
- origin=agencies.example.com environment=production --admin --json
182
+ project=agencies.example.com environment=production --admin --json
143
183
  astrale call "@$SERVICE::services.astrale.ai:class.CloudflareService.method.promote" \
144
184
  label=$PREVIOUS_LABEL --admin --json
145
185
  ```
@@ -158,6 +198,62 @@ deployment. It answers `deleting` with `retryAfterMs` until the last one is reti
158
198
  the Service with its deployments in one Mutation and answers `deleted`. The name is then free
159
199
  again.
160
200
 
201
+ ## Generic Services
202
+
203
+ The package ships an isolated scaffolding binary (the root import remains Schema-only):
204
+
205
+ ```sh
206
+ npm exec --package @astrale-domains/services -- create-astrale-service nightly-report
207
+ cd nightly-report
208
+ npm install
209
+ # Edit service.json and src/job.js; put the secret values in .env.production.
210
+ npm run deploy -- production
211
+ ```
212
+
213
+ The generator creates local source only and refuses an existing destination: a plain Worker
214
+ (`src/index.js` answering `GET /health` and running `src/job.js` on `POST /__scheduled`), an
215
+ optional ES256 account session to call an Astrale Kernel (`src/account.js`), `service.json` (the
216
+ project, the Workers runtime, the variables, the secret names, readiness paths and crons) and its
217
+ build (esbuild) and deploy scripts. `npm run deploy -- <environment>` builds it, deploys it through
218
+ `@astrale-os/adapter-astrale/services` as one immutable deployment promoted by default
219
+ (`--no-promote` deploys beside the current one), then replaces the Service's crons with
220
+ `service.json`'s. It installs, registers, grants and schedules nothing else. The generated README
221
+ explains promotion and rollback, secrets, crons, logs and deletion. The scaffold lives in
222
+ [`tooling/scaffold`](tooling/scaffold); `pnpm package` copies it to `dist/scaffold`.
223
+
224
+ ## Migration to the agnostic model
225
+
226
+ The Kernel installs no Schema revision that existing data does not satisfy
227
+ (`DATA_MIGRATION_REQUIRED`), and the SDK has no install-time migration: a Service stored before
228
+ Services hosted any Worker names its project `origin`, and its deployments store the Domain's
229
+ `FrozenConfigurationV1` and `DeploymentRecordV1`. The transition is expand, migrate, contract:
230
+
231
+ 1. **Expand** — this revision admits both forms. Every reader reads a legacy node as the Worker
232
+ deployment it is (`convertLegacyDeployment`): its build digest is its record's; its
233
+ configuration binds what the runtime that uploaded it bound (the compatibility date and flags,
234
+ the author's variables, `ASTRALE_SETTINGS` encoded from the frozen settings as that runtime
235
+ encoded it, the secret names, `record: true`, `signingIdentity: true`, `router` as it was); its
236
+ record is the RFC 8785 JSON its activation published, with the Astrale record media type. Its
237
+ label stays: a stored label is never derived again. Every writer writes the agnostic form only.
238
+ 2. **Migrate** — the Kernel's Root calls `CloudflareService.migrate` once. Each legacy Service and
239
+ deployment is rewritten by its own fenced Mutation (`origin` becomes `project`; a deployment
240
+ stores its reading), nothing is written at Cloudflare, and a rerun rewrites nothing more. It
241
+ answers how many it rewrote and names any deployment it could not read (`unconverted`, empty
242
+ when everything converted).
243
+ 3. **Contract** — the next revision drops the legacy shapes and `migrate`; the Kernel installs it
244
+ only once no legacy value remains.
245
+
246
+ Call it as the Admin Kernel's Root, with a short-lived root credential file (`--creds`) removed
247
+ once the call answers:
248
+
249
+ ```sh
250
+ astrale call "/:services.astrale.ai:class.CloudflareService:migrate" --admin --json \
251
+ --creds "$ROOT_CREDENTIAL_FILE"
252
+ ```
253
+
254
+ It answers `{ "services": <n>, "deployments": <n>, "unconverted": [] }`; a second call answers
255
+ zeros.
256
+
161
257
  ## Admin-hosted runtime
162
258
 
163
259
  Services are hosted for the Admin instance's Kernel alone: its graph is the registry of Service
@@ -173,14 +269,16 @@ hosting var.
173
269
 
174
270
  `pnpm run deploy admin` makes one immutable deployment of this release in the platform dispatch
175
271
  namespace. Deploying never installs: install the deployed URL with
176
- `astrale domain install <url> -i <instance>`. Operator tooling (platform namespace bootstrap,
177
- dispatcher, routing, audits) is described in
272
+ `astrale domain install <url> -i <instance>`. The Cloudflare Provider and its operator tooling
273
+ are built on the hosting kit: provisioning both dispatchers (the Services environment's and the
274
+ platform namespace's), audits, platform retirement and secret rotation are described in
178
275
  [`providers/cloudflare/operator/README.md`](providers/cloudflare/operator/README.md).
179
276
 
180
277
  ## Domain development
181
278
 
182
- Services is developed against its tests: the fake Cloudflare account and the
183
- [real-Kernel suite](tests/kernel/README.md).
279
+ Services is developed against its tests: the hosting kit's fake Cloudflare account
280
+ (`@astrale-os/adapter-cloudflare/hosting/testing`, running the dispatchers as uploaded, and the
281
+ scaffolded Worker itself) and the [real-Kernel suite](tests/kernel/README.md).
184
282
 
185
283
  ```sh
186
284
  pnpm typecheck
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import { cp, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { basename, resolve } from 'node:path'
4
+
5
+ const USAGE = 'Usage: create-astrale-service <new-directory>'
6
+ /** A project name Services admits (`PROJECT_NAME`), kept to a directory-friendly subset. */
7
+ const PROJECT = /^[a-z][a-z0-9-]{0,62}$/u
8
+ /** The packages a generated Service depends on, at the versions this package was released with. */
9
+ const PINNED = ['@astrale-os/sdk', '@astrale-os/adapter-astrale', 'esbuild', 'jose']
10
+
11
+ const [directory, ...extra] = process.argv.slice(2)
12
+ if (directory === '--help' || directory === '-h') {
13
+ console.log(
14
+ `${USAGE}\nCreates the local source of a Cloudflare Worker that Admin's Services hosts: an HTTP` +
15
+ ' entry, a cron job and its build and deploy scripts. It installs, registers, deploys and' +
16
+ ' schedules nothing.',
17
+ )
18
+ } else {
19
+ try {
20
+ if (!directory || directory.startsWith('-') || extra.length > 0) throw new Error(USAGE)
21
+ const destination = resolve(directory)
22
+ const name = basename(destination)
23
+ if (!PROJECT.test(name)) {
24
+ throw new Error(
25
+ 'Use a lower-case project name beginning with a letter (letters, digits and dashes, at most 63 characters).',
26
+ )
27
+ }
28
+ const versions = await pinnedVersions()
29
+ // Exclusive creation keeps an existing project and refuses a symbolic link.
30
+ await mkdir(destination)
31
+ const template = new URL('./template/', import.meta.url)
32
+ for (const entry of await readdir(template)) {
33
+ await cp(new URL(entry, template), resolve(destination, entry), {
34
+ recursive: true,
35
+ force: false,
36
+ errorOnExist: true,
37
+ })
38
+ }
39
+ await rename(resolve(destination, 'gitignore'), resolve(destination, '.gitignore'))
40
+ await writeJson(resolve(destination, 'package.json'), {
41
+ name,
42
+ version: '0.0.0',
43
+ private: true,
44
+ type: 'module',
45
+ scripts: {
46
+ build: 'node scripts/build.mjs',
47
+ deploy: 'node scripts/deploy.mjs',
48
+ },
49
+ dependencies: {
50
+ '@astrale-os/sdk': versions['@astrale-os/sdk'],
51
+ jose: versions.jose,
52
+ },
53
+ devDependencies: {
54
+ '@astrale-os/adapter-astrale': versions['@astrale-os/adapter-astrale'],
55
+ esbuild: versions.esbuild,
56
+ },
57
+ engines: { node: '>=22.13' },
58
+ })
59
+ await writeJson(resolve(destination, 'service.json'), {
60
+ project: name,
61
+ compatibilityDate: '2026-09-01',
62
+ compatibilityFlags: ['nodejs_compat'],
63
+ vars: { KERNEL: '', TARGET: '' },
64
+ secrets: ['ACCOUNT_PRIVATE_JWK'],
65
+ readiness: ['/health'],
66
+ crons: [],
67
+ })
68
+ console.log(
69
+ `Created ${destination}\nInstall its dependencies, edit service.json and src/job.js, then deploy:` +
70
+ ' npm run deploy -- <environment>. Its README explains each step.',
71
+ )
72
+ } catch (error) {
73
+ console.error(
74
+ error.code === 'EEXIST' ? 'Destination already exists; no file was written.' : error.message,
75
+ )
76
+ process.exitCode = 1
77
+ }
78
+ }
79
+
80
+ /** The versions of `PINNED` this package declares, in its dependencies or devDependencies. */
81
+ async function pinnedVersions() {
82
+ const owner = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'))
83
+ const declared = { ...owner.devDependencies, ...owner.dependencies }
84
+ return Object.fromEntries(
85
+ PINNED.map((name) => {
86
+ if (typeof declared[name] !== 'string') throw new Error(`No version of ${name} is pinned.`)
87
+ return [name, declared[name]]
88
+ }),
89
+ )
90
+ }
91
+
92
+ async function writeJson(path, value) {
93
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' })
94
+ }
@@ -0,0 +1,4 @@
1
+ # The secrets service.json declares, for one environment: copy to .env.<environment> (ignored by
2
+ # git). `npm run deploy -- <environment>` reads them and binds them to the new deployment; they are
3
+ # never stored by Services. One value per line, as NAME=value.
4
+ ACCOUNT_PRIVATE_JWK=
@@ -0,0 +1,75 @@
1
+ # Astrale Service
2
+
3
+ A plain Cloudflare Worker that Admin's Services hosts (`services.astrale.ai`): an HTTP entry and a
4
+ cron job, with no Cloudflare account of your own. It is not an Astrale Domain: it publishes no
5
+ Schema and installs nothing. Services knows nothing of what it serves either: it runs the Worker
6
+ you build, at its own URL, under your Identity.
7
+
8
+ - `src/index.js` answers `GET /health` and runs `src/job.js` on `POST /__scheduled`; every other
9
+ path answers 404. Add your routes there.
10
+ - `src/job.js` calls one callable of an Astrale Kernel as this Service's own account
11
+ (`src/account.js`, an ES256 key registered on a dedicated Identity). Replace it with your job.
12
+ - `service.json` is what every deployment freezes beside its code: the project (with an environment,
13
+ it names the Service), the Workers compatibility date and flags, plain variables, the names of its
14
+ secrets, the readiness paths probed before a deployment is activated, and the Service's crons.
15
+
16
+ ## Deploy
17
+
18
+ Install the dependencies (Node 22.13+) and the Astrale CLI, and sign in: `astrale auth login`.
19
+ Services is called on the Admin instance as the CLI's identity.
20
+
21
+ ```sh
22
+ npm install
23
+ cp .env.example .env.production # then fill in the secret values; git ignores it
24
+ npm run deploy -- production # --admin <bookmark>, --as <identity>, --no-promote
25
+ ```
26
+
27
+ `deploy` builds `.dist/worker.mjs`, then calls `CloudflareService.deploy` through
28
+ `@astrale-os/adapter-astrale/services` (`readWorkerDirectory`, `deployWorker`). The first deploy of
29
+ a project and an environment creates their Service, owned by you, or by the Identity you act as with
30
+ `--as`. Each deploy is one immutable deployment at `https://<label>.<routing domain>`; the Service's
31
+ URL, `https://<service>.<routing domain>`, serves the one last promoted, by default the new one. The
32
+ same code and configuration reuse their deployment and rewrite its secrets in place when their
33
+ values changed; any other change is a new deployment. Secret values come from `.env.<environment>`
34
+ (or the process environment) and are never stored by Services, nor printed.
35
+
36
+ `deploy` answers the deployment's `label`, `url` and the `serviceId` every Service Method is called
37
+ on, then replaces the Service's crons with `service.json`'s `crons`.
38
+
39
+ ## Operate
40
+
41
+ Call the Service's Methods on the Admin instance with its id, as its owner:
42
+
43
+ ```sh
44
+ SERVICE=@<serviceId>::services.astrale.ai:class.CloudflareService
45
+ astrale call "$SERVICE.method.promote" label=<label> --admin --json # roll back or forward
46
+ astrale call "$SERVICE.method.setSchedule" --admin -d '{"crons":["*/5 * * * *"]}' --json
47
+ astrale call "$SERVICE.method.schedules" --admin --json
48
+ astrale call "$SERVICE.method.logs" --admin -d '{"tail":50}' --json
49
+ astrale call "$SERVICE.method.setSecret" --admin -d @secret.json --json # { "name", "value" }
50
+ astrale call "$SERVICE.method.secrets" --admin --json # names only
51
+ astrale call "$SERVICE.method.delete" --admin --json
52
+ astrale call /:services.astrale.ai:class.CloudflareService:list \
53
+ project=<project> environment=production --admin --json
54
+ ```
55
+
56
+ - **Promote, roll back**: `promote` points the Service's URL at one of its active deployments.
57
+ - **Retire**: `astrale call "@<deploymentId>::services.astrale.ai:class.CloudflareDeployment.method.retire" --admin`
58
+ withdraws one deployment for good, in two calls about 65 seconds apart. The current deployment is
59
+ never retired alone: promote another one first, or delete the Service.
60
+ - **Secrets**: `setSecret` rewrites one declared secret of the current deployment in place; write
61
+ its input to a file only you can read, never in argv. A new secret name is a new deployment: add
62
+ it to `service.json` and deploy. Put the new value in `.env.<environment>` too: the next deploy
63
+ binds the values it carries.
64
+ - **Crons**: five-field UTC expressions. Each due cron is posted by the Services dispatcher to the
65
+ deployment the Service serves, as `POST https://cron.internal/__scheduled` with `x-astrale-cron`
66
+ (the expression) and `x-astrale-scheduled-time` (the minute it fired for, in epoch ms), within a
67
+ 10 s deadline. The public never reaches `/__scheduled`, in any spelling, and a run is at most once
68
+ per minute but not exactly once: make the job safe to repeat.
69
+ - **Logs**: `logs` reads the recent logs of the current deployment, or of one by `label`.
70
+ - **Delete**: `delete` stops routing the Service and its crons, retires every deployment, then
71
+ deletes the Service and frees its name; call it again after `retryAfterMs` until it answers
72
+ `deleted`.
73
+
74
+ `/health` proves the Worker runs, not that its account is registered or authorized: check the job's
75
+ real call once, then add its cron.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .dist/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.private.jwk
@@ -0,0 +1,20 @@
1
+ import { build } from 'esbuild'
2
+ import { resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ /** Bundle the Worker into one ES module, `.dist/worker.mjs`, as Workers loads it. */
6
+ export async function buildWorker(directory = process.cwd()) {
7
+ await build({
8
+ absWorkingDir: resolve(directory),
9
+ entryPoints: ['src/index.js'],
10
+ outfile: '.dist/worker.mjs',
11
+ bundle: true,
12
+ platform: 'browser',
13
+ conditions: ['workerd', 'worker', 'browser'],
14
+ format: 'esm',
15
+ target: 'es2022',
16
+ logLevel: 'warning',
17
+ })
18
+ }
19
+
20
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) await buildWorker()
@@ -0,0 +1,65 @@
1
+ import {
2
+ callService,
3
+ deployWorker,
4
+ readWorkerDirectory,
5
+ } from '@astrale-os/adapter-astrale/services'
6
+ import { parseArgs } from 'node:util'
7
+
8
+ import { buildWorker } from './build.mjs'
9
+ import { readSecrets, readService, serviceDeployment } from './service.mjs'
10
+
11
+ const USAGE =
12
+ 'Usage: npm run deploy -- <environment> [--admin <bookmark>] [--as <identity>] [--no-promote]'
13
+
14
+ const { positionals, values } = parseArgs({
15
+ args: process.argv.slice(2).filter((argument) => argument !== '--'),
16
+ allowPositionals: true,
17
+ options: {
18
+ admin: { type: 'string' },
19
+ as: { type: 'string' },
20
+ 'no-promote': { type: 'boolean', default: false },
21
+ },
22
+ })
23
+ const [environment, ...extra] = positionals
24
+ if (environment === undefined || extra.length > 0) throw new Error(USAGE)
25
+
26
+ const directory = process.cwd()
27
+ const service = await readService(directory)
28
+ const secrets = await readSecrets(directory, environment, service.secrets ?? [])
29
+ await buildWorker(directory)
30
+ const worker = await readWorkerDirectory('.dist', { mainModule: 'worker.mjs' })
31
+ const options = {
32
+ ...(values.admin === undefined ? {} : { admin: values.admin }),
33
+ ...(values.as === undefined ? {} : { identity: values.as }),
34
+ }
35
+ const promote = !values['no-promote']
36
+ // One immutable deployment: the same code and configuration reuse their deployment, and the
37
+ // Service's URL serves it once it is active unless --no-promote.
38
+ const {
39
+ state,
40
+ secrets: bound,
41
+ deployment,
42
+ } = await deployWorker(
43
+ serviceDeployment({ service, environment, worker, secrets, promote }),
44
+ options,
45
+ )
46
+ // The Service's crons are service.json's, fired on the deployment its URL serves.
47
+ const crons =
48
+ promote && service.crons !== undefined
49
+ ? await callService(deployment.serviceId, 'setSchedule', { crons: service.crons }, options)
50
+ : undefined
51
+ if (crons?.kind === 'rejected') process.exitCode = 1
52
+ console.log(
53
+ JSON.stringify(
54
+ {
55
+ state,
56
+ secrets: bound.state,
57
+ serviceId: deployment.serviceId,
58
+ deployment: { id: deployment.id, label: deployment.label, url: deployment.url },
59
+ current: deployment.current,
60
+ ...(crons === undefined ? {} : { schedules: crons }),
61
+ },
62
+ null,
63
+ 2,
64
+ ),
65
+ )
@@ -0,0 +1,69 @@
1
+ import { workerConfiguration } from '@astrale-os/sdk/deployment/worker'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * `service.json`: what every deployment of this Service freezes beside its code. Its project and
7
+ * an environment name the Service; a changed variable, flag or secret name is a new deployment.
8
+ */
9
+ export async function readService(directory) {
10
+ const service = JSON.parse(await readFile(join(directory, 'service.json'), 'utf8'))
11
+ for (const member of ['project', 'compatibilityDate']) {
12
+ if (typeof service[member] !== 'string') throw new TypeError(`service.json needs ${member}.`)
13
+ }
14
+ return service
15
+ }
16
+
17
+ /**
18
+ * The value of each secret `service.json` declares, for one environment: from
19
+ * `.env.<environment>` when it exists, else from the process environment. A missing one refuses
20
+ * the deploy before anything is called; values are never printed.
21
+ */
22
+ export async function readSecrets(directory, environment, declared, variables = process.env) {
23
+ const file = await readFile(join(directory, `.env.${environment}`), 'utf8').catch((error) => {
24
+ if (error.code === 'ENOENT') return undefined
25
+ throw error
26
+ })
27
+ const values = file === undefined ? variables : parseEnv(file)
28
+ const missing = declared.filter((name) => typeof values[name] !== 'string' || values[name] === '')
29
+ if (missing.length > 0) {
30
+ throw new Error(
31
+ `Set ${missing.join(', ')} in .env.${environment} or in the environment before deploying.`,
32
+ )
33
+ }
34
+ return Object.fromEntries(declared.map((name) => [name, values[name]]))
35
+ }
36
+
37
+ /**
38
+ * The deployment of one built Worker in one environment, as `deployWorker`
39
+ * (`@astrale-os/adapter-astrale/services`) sends it: no record, no signing identity, no router,
40
+ * the readiness paths `service.json` names.
41
+ */
42
+ export function serviceDeployment({ service, environment, worker, secrets, promote = true }) {
43
+ return {
44
+ project: service.project,
45
+ environment,
46
+ worker,
47
+ configuration: workerConfiguration({
48
+ compatibilityDate: service.compatibilityDate,
49
+ compatibilityFlags: service.compatibilityFlags ?? [],
50
+ vars: service.vars ?? {},
51
+ secrets: Object.keys(secrets),
52
+ }),
53
+ secrets,
54
+ ...(service.readiness === undefined ? {} : { readiness: service.readiness }),
55
+ promote,
56
+ }
57
+ }
58
+
59
+ /** `NAME=value` lines; blank lines and `#` comments are skipped, a value is taken as written. */
60
+ function parseEnv(text) {
61
+ const values = {}
62
+ for (const line of text.split(/\r?\n/u)) {
63
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
64
+ const separator = line.indexOf('=')
65
+ if (separator <= 0) throw new Error('A .env line is not NAME=value.')
66
+ values[line.slice(0, separator).trim()] = line.slice(separator + 1)
67
+ }
68
+ return values
69
+ }