@astrale-domains/services 0.4.4 → 0.4.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.
Files changed (87) hide show
  1. package/README.md +199 -7
  2. package/dist/scaffold/create-service.mjs +77 -0
  3. package/dist/scaffold/template/.env.example +2 -0
  4. package/dist/scaffold/template/README.md +37 -0
  5. package/dist/scaffold/template/gitignore +6 -0
  6. package/dist/scaffold/template/scripts/build.mjs +10 -0
  7. package/dist/scaffold/template/scripts/deploy.mjs +50 -0
  8. package/dist/scaffold/template/src/account.js +26 -0
  9. package/dist/scaffold/template/src/index.js +32 -0
  10. package/dist/scaffold/template/src/job.js +19 -0
  11. package/dist/schema/index.d.ts +2 -0
  12. package/dist/schema/index.js +1 -0
  13. package/dist/schema/modules/deployment/classes/cloudflare-deployment.d.ts +97 -0
  14. package/dist/schema/modules/deployment/classes/cloudflare-deployment.js +10 -0
  15. package/dist/schema/modules/deployment/classes/deployment-line.d.ts +23 -0
  16. package/dist/schema/modules/deployment/classes/deployment-line.js +24 -0
  17. package/dist/schema/modules/deployment/classes/deployment-on-line.d.ts +17 -0
  18. package/dist/schema/modules/deployment/classes/deployment-on-line.js +11 -0
  19. package/dist/schema/modules/deployment/classes/deployment.d.ts +98 -0
  20. package/dist/schema/modules/deployment/classes/deployment.js +32 -0
  21. package/dist/schema/modules/deployment/classes/index.d.ts +5 -0
  22. package/dist/schema/modules/deployment/classes/index.js +5 -0
  23. package/dist/schema/modules/deployment/classes/line-owned-by.d.ts +26 -0
  24. package/dist/schema/modules/deployment/classes/line-owned-by.js +19 -0
  25. package/dist/schema/modules/deployment/index.d.ts +6 -0
  26. package/dist/schema/modules/deployment/index.js +4 -0
  27. package/dist/schema/modules/deployment/policies/index.d.ts +4 -0
  28. package/dist/schema/modules/deployment/policies/index.js +4 -0
  29. package/dist/schema/modules/deployment/policies/own-deployment.d.ts +2 -0
  30. package/dist/schema/modules/deployment/policies/own-deployment.js +12 -0
  31. package/dist/schema/modules/deployment/policies/own-line.d.ts +5 -0
  32. package/dist/schema/modules/deployment/policies/own-line.js +10 -0
  33. package/dist/schema/modules/deployment/policies/traverse-owned-line-deployment.d.ts +2 -0
  34. package/dist/schema/modules/deployment/policies/traverse-owned-line-deployment.js +7 -0
  35. package/dist/schema/modules/deployment/policies/traverse-owned-line.d.ts +2 -0
  36. package/dist/schema/modules/deployment/policies/traverse-owned-line.js +7 -0
  37. package/dist/schema/modules/deployment/states/deployment-lifecycle.d.ts +27 -0
  38. package/dist/schema/modules/deployment/states/deployment-lifecycle.js +17 -0
  39. package/dist/schema/modules/deployment/states/index.d.ts +2 -0
  40. package/dist/schema/modules/deployment/states/index.js +1 -0
  41. package/dist/schema/modules/deployment/types/deployment-record.d.ts +35 -0
  42. package/dist/schema/modules/deployment/types/deployment-record.js +31 -0
  43. package/dist/schema/modules/deployment/types/deployment-retention.d.ts +13 -0
  44. package/dist/schema/modules/deployment/types/deployment-retention.js +5 -0
  45. package/dist/schema/modules/deployment/types/index.d.ts +4 -0
  46. package/dist/schema/modules/deployment/types/index.js +2 -0
  47. package/dist/schema/modules/service/classes/cloudflare-worker.d.ts +103 -37
  48. package/dist/schema/modules/service/classes/cloudflare-worker.js +4 -1
  49. package/dist/schema/modules/service/classes/service.d.ts +19 -19
  50. package/dist/schema/modules/service/functions/delete-service-secret.d.ts +1 -1
  51. package/dist/schema/modules/service/functions/delete-service.d.ts +1 -1
  52. package/dist/schema/modules/service/functions/delete-worker-secret.d.ts +1 -1
  53. package/dist/schema/modules/service/functions/delete-worker.d.ts +1 -1
  54. package/dist/schema/modules/service/functions/deploy-cloudflare-worker.d.ts +37 -1
  55. package/dist/schema/modules/service/functions/index.d.ts +1 -0
  56. package/dist/schema/modules/service/functions/index.js +1 -0
  57. package/dist/schema/modules/service/functions/list-service-logs.d.ts +1 -1
  58. package/dist/schema/modules/service/functions/list-service-schedules.d.ts +1 -1
  59. package/dist/schema/modules/service/functions/list-service-secret-names.d.ts +1 -1
  60. package/dist/schema/modules/service/functions/list-worker-logs.d.ts +1 -1
  61. package/dist/schema/modules/service/functions/list-worker-schedules.d.ts +1 -1
  62. package/dist/schema/modules/service/functions/list-worker-secret-names.d.ts +1 -1
  63. package/dist/schema/modules/service/functions/migrate-worker-ingress.d.ts +24 -0
  64. package/dist/schema/modules/service/functions/migrate-worker-ingress.js +26 -0
  65. package/dist/schema/modules/service/functions/replace-service-schedules.d.ts +1 -1
  66. package/dist/schema/modules/service/functions/replace-worker-schedules.d.ts +1 -1
  67. package/dist/schema/modules/service/functions/set-service-secret.d.ts +1 -1
  68. package/dist/schema/modules/service/functions/set-worker-secret.d.ts +3 -3
  69. package/dist/schema/modules/service/index.d.ts +2 -2
  70. package/dist/schema/modules/service/index.js +1 -1
  71. package/dist/schema/modules/service/types/content/index.d.ts +1 -0
  72. package/dist/schema/modules/service/types/content/index.js +1 -0
  73. package/dist/schema/modules/service/types/content/limits.d.ts +7 -0
  74. package/dist/schema/modules/service/types/content/limits.js +7 -0
  75. package/dist/schema/modules/service/types/deploy-cloudflare-worker.d.ts +110 -0
  76. package/dist/schema/modules/service/types/index.d.ts +2 -1
  77. package/dist/schema/modules/service/types/index.js +1 -1
  78. package/dist/schema/modules/service/types/legacy/service-deployment-content.js +15 -0
  79. package/dist/schema/modules/service/types/service-deployment-artifact-module.d.ts +23 -0
  80. package/dist/schema/modules/service/types/service-deployment-artifact-module.js +5 -3
  81. package/dist/schema/modules/service/types/service-deployment-assets.d.ts +36 -0
  82. package/dist/schema/modules/service/types/service-deployment-assets.js +5 -3
  83. package/dist/schema/modules/service/types/service-deployment-content.d.ts +18 -11
  84. package/dist/schema/modules/service/types/service-deployment-content.js +17 -8
  85. package/dist/schema/schema.d.ts +365 -62
  86. package/dist/schema/schema.js +10 -0
  87. package/package.json +14 -12
package/README.md CHANGED
@@ -11,14 +11,40 @@ protocols behind neutral SDK Integrations and stores only admitted lifecycle evi
11
11
  - Owners may be Shell users, local Kernel roots, or application/CI identities. Services requires only
12
12
  the Kernel Schema; a Shell installation is not required to deploy. Ownership
13
13
  remains exact-subject scoped, and existing Shell-user ownership edges remain valid.
14
+ - a `DeploymentLine` is one `(origin, environment)`, named by its SDK fingerprint. `line_owned_by`
15
+ gives it to at most one organisation Identity, first come. Members and CI identities read the
16
+ line and its deployments through Kernel `extends_with` (`OwnLine`, `OwnDeployment`). A line is
17
+ never reassigned. Deleting its deployments, the organisation's Services or the organisation
18
+ itself leaves the line refused to every other organisation (`line-owned-elsewhere`). Only an
19
+ operator transfer moves the holder Edge.
20
+ - the line Queries, `admitDeploymentLine` and the deployment Mutations run under the Services
21
+ Domain credential and trust the organisation they are given. Under a caller's credential the
22
+ Policies hide a foreign line, which would read as a free one. Their caller must first prove that
23
+ the requesting identity is that organisation or acts for it: `Identity.can` on `OwnLine` for a
24
+ held line, the identity itself or its `extends_with` for a registration.
25
+ - each `Deployment` (`CloudflareDeployment`) sits on one line through `deployment_on_line` and is
26
+ visible to whoever holds that line. A deployment is recorded only while its organisation holds
27
+ the line. Its label must name the line, and its record must name the line's origin and
28
+ environment. [Real-Kernel proof](tests/kernel/README.md).
29
+ - an operator deleting an organisation that holds lines deletes its `line_owned_by` Edges in the
30
+ same Mutation: the Kernel deletes a Node only together with every incident Edge. Its lines stay
31
+ registered with no holder and are refused to every organisation. A transfer is a root
32
+ `createEdge` of `line_owned_by` from the line to the new organisation; a line has one holder at
33
+ most.
14
34
  - secret values stay write-only at Cloudflare and never enter Astrale mutations or graph state.
15
35
  - schedules remain provider state; only the owner may read or replace them.
16
36
  - logs are remote evidence admitted by the Cloudflare Provider before a Workflow returns them.
17
37
  - the only admitted Worker service binding is the logical platform router; the provider resolves
18
38
  it to the selected environment and callers cannot name arbitrary same-account Workers.
39
+ - tenant Workers do not receive namespace dispatch authority. Inter-Service requests use public
40
+ ingress, including its retirement and private-route checks. Only the platform dispatcher and
41
+ candidate-readiness operator retain their respective native dispatch bindings.
19
42
  - the provider supplies `WORKER_URL` as the Service's canonical HTTPS URL to both candidate and
20
43
  public Workers. It is reserved provider metadata, not a caller variable or secret. Applications
21
44
  therefore keep one issuer even when requests arrive over HTTP or through a dispatcher alias.
45
+ - `ASTRALE_BUILD_DIGEST` is reserved the same way: a Worker's build digest is bound by its host,
46
+ never by its author, so Services refuses it as a caller variable or secret on every deployment
47
+ path and in secret management.
22
48
 
23
49
  The View, CLI, and agent consumers read the named Service Queries directly; there is intentionally
24
50
  no list Function wrapping `listServicesQuery`. Deployment, secrets, schedules, logs, and deletion
@@ -27,17 +53,159 @@ are authorized by the ownership Policy before their Workflows begin. `service-ho
27
53
  is the provider-neutral compute Integration contract. Cloudflare is its first Provider.
28
54
 
29
55
  `Service` declares seven abstract management contracts. `CloudflareWorker` owns their executable
30
- Methods (`setSecret`, `deleteSecret`, `secrets`, `setSchedule`, `schedules`, `logs`, `delete`) and
56
+ Methods (`setSecret`, `deleteSecret`, `secrets`, `setSchedule`, `schedules`, `logs`, `migrateIngress`, `delete`) and
31
57
  applies `OwnService` to each one. Workflow addresses are `CloudflareWorker.*`; abstract `Service.*`
32
58
  keys are not executable. A future provider Class must supply its own concrete declarations and
33
59
  implementations. Properties and ownership relationships retain their inherited meaning.
34
60
 
35
- ## Development
61
+ ## Shared public ingress
62
+
63
+ New Services use the environment's proxied wildcard DNS, wildcard Worker route and wildcard
64
+ certificate. Services observes the expected dispatcher route and probes a never-registered hostname
65
+ before uploading a Worker without an existing Custom Domain. It never creates a per-Service Custom
66
+ Domain or certificate. Missing shared ingress is an operator provisioning issue; it is not repaired
67
+ by consuming another certificate during a Service deployment.
68
+
69
+ After activating routing, deployment waits for public dispatcher visibility with a bounded budget.
70
+ This is separate from optional candidate application health checks. An uncertain activation retains
71
+ the Service record and can be retried with the same owner-scoped service key.
72
+
73
+ Existing exact Custom Domains and their immutable certificate locators are retained on ordinary
74
+ updates. Detaching old hostnames is a separate migration, coordinated with Service operations;
75
+ upgrading this Runtime does not perform that migration. A Service without an exact domain persists
76
+ `tenantDomainState: 'absent'`.
77
+ Deletion handles both placements and never removes shared ingress infrastructure.
78
+
79
+ Cloudflare callers that must traverse same-zone public Worker routes need
80
+ `global_fetch_strictly_public`. Audit existing callers before migrating their exact hostnames;
81
+ explicit runtime flags retain their authored meaning. A rollback after creating wildcard Services
82
+ must use a Runtime that understands explicit absent placement, not an older certificate-required
83
+ implementation.
84
+
85
+ ### Migrating existing Services
86
+
87
+ The owner calls `CloudflareWorker.migrateIngress` with an `action` (`detach`, `complete`, or
88
+ `restore`) and a deterministic public health `probe`: a local GET `path`, expected successful
89
+ `status`, and the exact response body's SHA-256 in `bodySha256`. The Workflow acquires the existing
90
+ Service operation lease and verifies provider ownership, active dispatch, wildcard TLS and health
91
+ before detaching the exact Custom Domain. It does not redeploy the Worker or alter its routing KV,
92
+ secrets or schedules.
93
+
94
+ After `detach`, observe real traffic for at least 30 minutes before calling `complete`. Until
95
+ completion, the graph retains the previous domain and certificate locators with
96
+ `tenantDomainDetachedAt`; completion clears them after provider cleanup and another successful
97
+ health check. Cloudflare can automatically delete the generated certificate as soon as its Custom
98
+ Domain is detached. `restore` recreates the exact domain, waits for active TLS and records the actual
99
+ replacement certificate locator. A `pending` result requires a later retry; it is not completion.
100
+
101
+ The consumer runtimes are frozen ([`frozen-environments.json`](./frozen-environments.json)): the
102
+ original R1 runtime at `https://services.beta.astrale.ai`, which 1Pact instances pin and which
103
+ serves that issuer's discovery and keys, `https://services-v2.beta.astrale.ai`, which retains that
104
+ issuer and key, and production at `https://services.astrale.ai`. They keep serving the code they run
105
+ today and no Environment deploys them. Do not uninstall Services or replace a runtime underneath
106
+ installations that still use its Schema. An instance rollback must restore any detached domain
107
+ before reinstalling the original endpoint, and must first be qualified with real retained Service
108
+ data.
109
+
110
+ ## Admin-hosted runtime
111
+
112
+ The `admin-beta` Environment is the Services runtime of the Admin instance, the one that hosts
113
+ publishers' deployments outside the instances that install them. It is a Worker of its own,
114
+ `services-domain-admin-beta` at `https://services-admin.beta.astrale.ai`, on the beta data plane the
115
+ Admin instance's Services already manage. Its plain vars name the router Worker
116
+ (`ASTRALE_ROUTER_WORKER`), the routing domain under which deployments are addressed
117
+ (`SERVICES_DEPLOYMENT_HOST`, served by the Services dispatcher) and the registry Domain
118
+ (`SERVICES_REGISTRY_ORIGIN`). `.env.admin-beta` must set `SERVICES_SECRET_DIGEST_KEY`, the key of
119
+ each hosted deployment's secret digest. The runtime keys HMAC-SHA256 with the UTF-8 bytes of the value
120
+ as written, never base64-decoded, so the configuration refuses, before any deployment effect, a file
121
+ without it or with a value shorter than 32 UTF-8 bytes.
122
+
123
+ It retains the issuer `https://services.beta.astrale.ai` and its signing key, as services-v2 does.
124
+ The Admin instance's Services are bound to that issuer, and the Kernel refuses an issuer change when
125
+ it replaces an installed Domain (`issuer-changed`); neither the Admin Host's Kernel nor the CLI
126
+ offers a consented issuer change yet. The cost: a Worker built from main holds the key that 1Pact's
127
+ installations trust. Keep the key only in managed secret storage and in this Worker's
128
+ `ASTRALE_SIGNING_IDENTITY` secret. The Admin instance moves to an issuer of its own when the
129
+ canonical Admin-hosted runtime replaces this one through a consented issuer change; this Worker and
130
+ its copy of the key are retired after that reinstall.
131
+
132
+ Only the Admin instance installs this runtime; consumer instances keep the Services they pin.
133
+ The operator sets it up once:
134
+
135
+ 1. Read what the Admin instance's Services are bound to, read-only:
136
+ `astrale introspect services.astrale.ai -i <admin-instance> --json`. Record `revision` and
137
+ `publication.identity.issuer`, and the frozen runtime whose `/.well-known/astrale/domain.json`
138
+ declares that revision: it is the rollback URL. Go on only if the issuer is
139
+ `https://services.beta.astrale.ai`; any other issuer makes step 6 an issuer change, which the
140
+ Kernel refuses.
141
+ 2. Create `.env.admin-beta` (mode 600) from `.env.example` with the beta data-plane coordinates the
142
+ Admin instance's Services manage today (account, token, dispatch namespace, routing domain
143
+ `svc.eu.beta.astrale.ai`, schedule KV, dispatcher, zone, Admin target) and a new
144
+ `SERVICES_SECRET_DIGEST_KEY` that no other environment uses, e.g. `openssl rand -base64 32` (32
145
+ random bytes, kept as their 44-character base64 text).
146
+ 3. Restore the retained `https://services.beta.astrale.ai` signing identity, the one services-v2 was
147
+ deployed with, from managed secret storage to `.astrale/services-beta-identity.json` (mode 600).
148
+ Never generate a key for this runtime: the frozen R1 Worker publishes the issuer's keys, so a new
149
+ key would never verify. Never leave that key at `.astrale/identity.json`, the development
150
+ Environment's default identity: `pnpm dev` would bind it to development Workers. Check that the
151
+ issuer publishes the restored key:
152
+
153
+ ```sh
154
+ node --input-type=module -e '
155
+ const { readFileSync } = await import("node:fs")
156
+ const { kid, x } = JSON.parse(readFileSync(".astrale/services-beta-identity.json", "utf8"))
157
+ const issuer = "https://services.beta.astrale.ai"
158
+ const { jwks_uri } = await (await fetch(`${issuer}/.well-known/openid-configuration`)).json()
159
+ const { keys } = await (await fetch(jwks_uri)).json()
160
+ if (!keys.some((key) => key.kid === kid && key.x === x)) throw new Error("key not published")
161
+ console.log(`issuer publishes kid ${kid}`)
162
+ '
163
+ ```
164
+
165
+ 4. Deploy once with `pnpm run deploy admin-beta`. It creates only the `services-domain-admin-beta`
166
+ Worker and its route; nothing is installed. Do not run `pnpm provider:bootstrap` or a
167
+ `provider:route-*` command for this runtime: on the beta data plane they redeploy the shared
168
+ dispatcher and DNS routing that serve 1Pact's tenants (see the
169
+ [operator README](./providers/cloudflare/operator/README.md)).
170
+ 5. Rehearse step 6 first on a disposable instance whose Host runs the Admin Host's Kernel release and
171
+ whose Services are bound to the same issuer and revision as in step 1, with at least one Service.
172
+ The install must succeed and the Service must stay manageable (list, secrets, logs). Then install
173
+ the step 1 rollback URL there again: if the Kernel refuses it, step 6 is one-way, and that must be
174
+ recorded before step 6 runs.
175
+ 6. Install on the Admin instance only, directly:
176
+ `astrale domain install https://services-admin.beta.astrale.ai --direct --allow-identity-override -i <admin-instance>`.
177
+ The only consent is the identity override, because the origin `services.astrale.ai` differs from
178
+ the serving host; the issuer does not change. On any refusal, stop: never uninstall Services on
179
+ the Admin instance to get around it, which would drop the Service records it holds. Never repoint
180
+ Admin's catalog entry for `services.astrale.ai` at this runtime: consumer flows, 1Pact's among
181
+ them, install Services by origin through that entry.
182
+
183
+ ## Plain background Service starter
184
+
185
+ The package also ships an isolated scaffolding binary (the root import remains Schema-only):
186
+
187
+ ```sh
188
+ npm exec --package @astrale-domains/services -- create-astrale-service my-worker
189
+ cd my-worker
190
+ npm install
191
+ # Edit service.json and src/job.js for the existing business Domain.
192
+ npm run deploy -- --instance development --as developer
193
+ ```
194
+
195
+ The generator creates local source only and refuses an existing destination. It does not install
196
+ dependencies, create/register accounts, grant permissions, set secrets, enable schedules or deploy.
197
+ The generated project owns a plain HTTP/cron entry, an ES256 account session, a job implementation
198
+ and build/deploy scripts. No Application, Schema, Publication, Domain signer or frontend is generated.
199
+ Deployment calls the public Services API through the installed Astrale CLI and requires an explicit
200
+ instance or URL. The project README explains account setup, secret custody, private cron and cleanup.
201
+ No tooling dependency is added to the Schema runtime or imported from the package root.
202
+
203
+ ## Domain development
36
204
 
37
205
  Copy `.env.example` to `.env.dev` and fill the Cloudflare values.
38
206
 
39
- `pnpm dev` watches and deploys the development Environment remotely. `pnpm dev beta`
40
- selects beta; `pnpm beta` and `pnpm prod` deploy once. These bootstrap Environments
207
+ `pnpm dev` watches and deploys the development Environment remotely. `pnpm run deploy admin-beta`
208
+ deploys the [Admin-hosted runtime](#admin-hosted-runtime) once. These bootstrap Environments
41
209
  are deliberately deploy-only: install the resulting URL on the intended Kernel explicitly.
42
210
  Stopping development leaves the remote Worker available. No local Worker or tunnel is started.
43
211
 
@@ -52,12 +220,36 @@ pnpm build
52
220
  pnpm package
53
221
  ```
54
222
 
55
- The deployed module named by `entry` must be present in `modules`. Module contents use one Base64
56
- string or bounded Base64 chunks when one string cannot cross the portable Value boundary; names are
57
- unique and secrets use environment-variable identifiers. Plain and revisioned artifacts are
223
+ The deployed module named by `entry` must be present in `modules`. Modules and assets carry
224
+ `content: { encoding: 'identity' | 'gzip', decodedSize, contentBase64 }`; large encoded files use
225
+ `contentBase64Chunks` instead, with each string bounded to 512 KiB. `decodedSize` is the exact original
226
+ byte length. Compression changes transport only: provider uploads and deployment digests use the
227
+ reconstructed bytes. Services validates complete gzip streams (including their checksum), exact sizes,
228
+ and canonical Base64 before any graph or provider operation. Individual decoded files remain bounded
229
+ to 7.5 MiB per module and 12 MiB per asset; all decoded deployment material shares a 32 MiB budget.
230
+
231
+ The previous top-level `contentBase64` and `contentBase64Chunks` fields remain accepted and are
232
+ explicitly deprecated. Roll out Services and install its new schema on consumer instances before
233
+ publishing the compressed SDK adapter. Old SDK clients continue working against the new Services;
234
+ new SDK requests are refused by an old schema without provider effects. The adapter never retries a
235
+ deployment using a different wire representation after an uncertain outcome.
236
+
237
+ Module names are unique and secrets use environment-variable identifiers. Plain and revisioned artifacts are
58
238
  distinct modes and cannot replace each other at one Service record. Five-field cron expressions are
59
239
  supported when `CLOUDFLARE_SCHEDULE_KV_ID` is configured.
60
240
 
241
+ Plain deployments that omit `compatibilityFlags` use `nodejs_compat`,
242
+ `nodejs_compat_populate_process_env`, and `global_fetch_strictly_public`. Public `fetch()` calls
243
+ therefore traverse Cloudflare's public routes, including same-zone Astrale endpoints, instead of
244
+ bypassing those routes for the zone's origin. This changes routing, not Kernel authorization.
245
+ An explicitly supplied flags array replaces these defaults, including `[]`; low-level callers
246
+ own that complete runtime configuration. Existing Workers change only on redeployment.
247
+
248
+ Revisioned deployments require explicit runtime configuration and upload it unchanged to both the
249
+ candidate and the serving Worker. Those exact flags participate in the deployment identity;
250
+ Services must not inject defaults after its computation. The Cloudflare adapter already includes
251
+ public-fetch routing in the generated configuration before building the artifact and its digest.
252
+
61
253
  SDK-authored Applications should deploy through `@astrale-os/adapter-astrale`, which projects the
62
254
  admitted Cloudflare artifact into the revisioned Services request and verifies the live Publication.
63
255
  Project tooling installs the exact Release only when that environment declares installation.
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { cp, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
3
+ import { basename, resolve } from 'node:path'
4
+
5
+ const [directory, ...extra] = process.argv.slice(2)
6
+ if (directory === '--help' || directory === '-h') {
7
+ console.log(
8
+ 'Usage: create-astrale-service <new-directory>\nCreates local source only. Does not install dependencies, register identities or deploy.',
9
+ )
10
+ } else {
11
+ try {
12
+ if (!directory || directory.startsWith('-') || extra.length) {
13
+ throw new Error('Usage: create-astrale-service <new-directory>')
14
+ }
15
+ const destination = resolve(directory)
16
+ const name = basename(destination)
17
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(name)) {
18
+ throw new Error(
19
+ 'Use a lowercase project name beginning with a letter (maximum 63 characters).',
20
+ )
21
+ }
22
+ const owner = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'))
23
+ // Exclusive creation preserves existing projects and refuses symlink targets.
24
+ await mkdir(destination)
25
+ const template = new URL('./template/', import.meta.url)
26
+ for (const entry of await readdir(template)) {
27
+ await cp(new URL(entry, template), resolve(destination, entry), {
28
+ recursive: true,
29
+ force: false,
30
+ errorOnExist: true,
31
+ })
32
+ }
33
+ await rename(resolve(destination, 'gitignore'), resolve(destination, '.gitignore'))
34
+ await writeFile(
35
+ resolve(destination, 'package.json'),
36
+ JSON.stringify(
37
+ {
38
+ name,
39
+ version: '0.0.0',
40
+ private: true,
41
+ type: 'module',
42
+ scripts: { build: 'node scripts/build.mjs', deploy: 'node scripts/deploy.mjs' },
43
+ dependencies: {
44
+ '@astrale-os/sdk': owner.dependencies['@astrale-os/sdk'],
45
+ zod: owner.dependencies.zod,
46
+ jose: '6.2.3',
47
+ },
48
+ devDependencies: { esbuild: '0.28.2' },
49
+ engines: { node: '>=22.13' },
50
+ },
51
+ null,
52
+ 2,
53
+ ) + '\n',
54
+ { flag: 'wx' },
55
+ )
56
+ await writeFile(
57
+ resolve(destination, 'service.json'),
58
+ JSON.stringify(
59
+ {
60
+ serviceKey: name,
61
+ vars: { KERNEL: '', TARGET: '' },
62
+ },
63
+ null,
64
+ 2,
65
+ ) + '\n',
66
+ { flag: 'wx' },
67
+ )
68
+ console.log(
69
+ `Created ${destination}\nInstall dependencies, edit service.json and src/job.js, then run npm run deploy -- --instance <instance> --as <identity>.`,
70
+ )
71
+ } catch (error) {
72
+ console.error(
73
+ error.code === 'EEXIST' ? 'Destination already exists; no files overwritten.' : error.message,
74
+ )
75
+ process.exitCode = 1
76
+ }
77
+ }
@@ -0,0 +1,2 @@
1
+ # Bind this through Services.setSecret; deployment does not read this file.
2
+ ACCOUNT_PRIVATE_JWK=
@@ -0,0 +1,37 @@
1
+ # Background Service
2
+
3
+ This is plain compute, not a Domain. It does not publish or install an Application.
4
+
5
+ 1. Install dependencies with `npm install` (Node22.13+), and install the Astrale CLI separately.
6
+ 2. Edit `service.json`: KERNEL is the exact Kernel issuer (including /api when present); TARGET is
7
+ the canonical callable Path. Edit `src/job.js` to match that callable's input and business intent.
8
+ 3. Create a dedicated Identity in its business Domain, register its ES256 key on the same Node,
9
+ and grant only the required callable authority and business permissions. Use Shell.Group.assignMember
10
+ for supported custom groups; do not use Root or admin credentials in this Worker.
11
+ 4. Run `npm run deploy -- --instance <instance> --as <owner>`. It builds first and calls Services.
12
+ The returned Service ID belongs to compute; it is not the registered Identity Node ID.
13
+ 5. Use `CloudflareWorker.setSecret` on that Service ID to bind ACCOUNT_PRIVATE_JWK. Send its value
14
+ through stdin, not shell arguments or service.json. `.env.example` only names the secret.
15
+ 6. Verify the account's real call, then enable the cron through `CloudflareWorker.setSchedule`.
16
+
17
+ ```sh
18
+ astrale call @SERVICE_ID::services.astrale.ai:class.CloudflareWorker.method.setSchedule \
19
+ --data '{"crons":["* * * * *"]}' -i INSTANCE --as OWNER --json
20
+ astrale call @SERVICE_ID::services.astrale.ai:class.CloudflareWorker.method.logs \
21
+ tail=20 -i INSTANCE --as OWNER --json
22
+ ```
23
+
24
+ The cron is a private Services-dispatched POST to /__scheduled, not a native Cloudflare scheduled
25
+ export. The public dispatcher rejects that path; headers alone are not authentication. The job
26
+ awaits the remote result before success and uses a 9s call budget under the current 10s dispatcher
27
+ deadline. Health indicates compute is reachable, not that the account is registered or authorized.
28
+ No exactly-once guarantee: design repeated or uncertain calls in the business Domain.
29
+
30
+ Deployment uses the Services provider's default public-fetch routing. It requires the Services
31
+ Runtime containing Domains PR210; publishing a schema package alone does not deploy that fix.
32
+ Explicit compatibilityFlags in service.json replace defaults completely. Configuration changes
33
+ require redeployment; no automatic fallback or provider-specific transport is embedded here.
34
+
35
+ To stop, set crons to [] and observe convergence. Delete the Service through its public delete
36
+ method, resuming a deleting result at retryAfterMs if necessary. Account/group/data retirement is
37
+ a separate business operation. Never delete shared groups or uninstall another Domain implicitly.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .dist/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.private.jwk
@@ -0,0 +1,10 @@
1
+ import { build } from 'esbuild'
2
+
3
+ await build({
4
+ entryPoints: ['src/index.js'],
5
+ outfile: '.dist/worker.mjs',
6
+ bundle: true,
7
+ platform: 'browser',
8
+ format: 'esm',
9
+ target: 'es2022',
10
+ })
@@ -0,0 +1,50 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { readFile } from 'node:fs/promises'
3
+
4
+ const args = process.argv.slice(2)
5
+ if (args[0] === '--') args.shift()
6
+ if (!args.some((arg) => arg === '-i' || /^(--instance|--url)(=|$)/.test(arg))) {
7
+ throw new Error(
8
+ 'Select a Services instance explicitly: npm run deploy -- --instance <instance> --as <identity>',
9
+ )
10
+ }
11
+ const configuration = JSON.parse(await readFile('service.json', 'utf8'))
12
+ await import('./build.mjs')
13
+ const content = (await readFile('.dist/worker.mjs')).toString('base64')
14
+ const request = {
15
+ ...configuration,
16
+ entry: 'index.mjs',
17
+ modules: [{ name: 'index.mjs', kind: 'esm', contentBase64Chunks: content.match(/.{1,524288}/g) }],
18
+ }
19
+ const child = spawn(
20
+ 'astrale',
21
+ ['call', '/:services.astrale.ai:class.CloudflareWorker:deploy', ...args, '--json'],
22
+ {
23
+ stdio: ['pipe', 'pipe', 'inherit'],
24
+ },
25
+ )
26
+ let output = ''
27
+ child.stdout.on('data', (chunk) => {
28
+ output += chunk
29
+ })
30
+ child.on('error', () => {
31
+ console.error('Could not start the Astrale CLI. Install @astrale-os/cli first.')
32
+ process.exitCode = 1
33
+ })
34
+ child.on('close', (code) => {
35
+ process.stdout.write(output)
36
+ if (code !== 0) {
37
+ process.exitCode = 1
38
+ return
39
+ }
40
+ try {
41
+ const result = JSON.parse(output)
42
+ if (result.state !== 'ready') process.exitCode = 1
43
+ } catch {
44
+ process.exitCode = 1
45
+ }
46
+ })
47
+ child.stdin.on('error', () => {
48
+ process.exitCode = 1
49
+ })
50
+ child.stdin.end(JSON.stringify(request))
@@ -0,0 +1,26 @@
1
+ import { issuer, jwk, register } from '@astrale-os/sdk/auth'
2
+ import { connect } from '@astrale-os/sdk/client/session'
3
+ import { importJWK, SignJWT } from 'jose'
4
+
5
+ export function openAccount(kernel, privateJwk) {
6
+ if (privateJwk.kty !== 'EC' || privateJwk.crv !== 'P-256' || !privateJwk.d) {
7
+ throw new TypeError('Expected a registered ES256 private JWK')
8
+ }
9
+ const publicKey = jwk.acceptPublic({ kty: 'EC', crv: 'P-256', x: privateJwk.x, y: privateJwk.y })
10
+ return connect({
11
+ url: kernel,
12
+ auth: {
13
+ ttlSeconds: 60,
14
+ resolve: async () => ({
15
+ credential: await new SignJWT({})
16
+ .setProtectedHeader({ alg: 'ES256', kid: privateJwk.kid, typ: 'JWT' })
17
+ .setIssuer(await register.selfIssuer(issuer.accept(kernel), publicKey))
18
+ .setSubject('self')
19
+ .setAudience(kernel)
20
+ .setIssuedAt()
21
+ .setExpirationTime('3m')
22
+ .sign(await importJWK(privateJwk, 'ES256')),
23
+ }),
24
+ },
25
+ })
26
+ }
@@ -0,0 +1,32 @@
1
+ import { runJob } from './job.js'
2
+
3
+ export default {
4
+ async fetch(request, env) {
5
+ const path = new URL(request.url).pathname
6
+ if (path === '/health' && request.method === 'GET') return Response.json({ status: 'ok' })
7
+ if (path !== '/__scheduled' || request.method !== 'POST')
8
+ return new Response(null, { status: 404 })
9
+ // Services invokes this through private dispatch and blocks the public path.
10
+ const scheduledAt = Number(request.headers.get('x-astrale-scheduled-time'))
11
+ if (!Number.isSafeInteger(scheduledAt) || scheduledAt <= 0)
12
+ return new Response(null, { status: 400 })
13
+ const started = Date.now()
14
+ try {
15
+ await runJob({ scheduledAt, env, signal: AbortSignal.timeout(9000) })
16
+ console.log(
17
+ JSON.stringify({ event: 'job.completed', scheduledAt, elapsedMs: Date.now() - started }),
18
+ )
19
+ return Response.json({ status: 'completed' })
20
+ } catch (error) {
21
+ // Do not log credentials, provider envelopes or business result values.
22
+ console.error(
23
+ JSON.stringify({
24
+ event: 'job.failed',
25
+ scheduledAt,
26
+ code: typeof error?.code === 'number' ? error.code : undefined,
27
+ }),
28
+ )
29
+ return Response.json({ status: 'failed' }, { status: 503 })
30
+ }
31
+ },
32
+ }
@@ -0,0 +1,19 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path'
2
+
3
+ import { openAccount } from './account.js'
4
+
5
+ // Adapt this input to the target Domain's contract. Repeat safety belongs there.
6
+ export async function runJob({ scheduledAt, env, signal }) {
7
+ if (!env.KERNEL || !env.TARGET || !env.ACCOUNT_PRIVATE_JWK) {
8
+ throw new Error('Configure KERNEL, TARGET and the ACCOUNT_PRIVATE_JWK secret')
9
+ }
10
+ const session = openAccount(env.KERNEL, JSON.parse(env.ACCOUNT_PRIVATE_JWK))
11
+ try {
12
+ return await session.call(
13
+ { target: Path.parse(env.TARGET), input: { scheduledAt } },
14
+ { signal },
15
+ )
16
+ } finally {
17
+ session.close()
18
+ }
19
+ }
@@ -3,3 +3,5 @@ export type { ServicesDomain, ServicesSchema } from './schema.js';
3
3
  export { artifact, artifactModule, assetBundle, assetFile, deployServiceInput, deployServiceOutput, resourceLimits, revisionedDeployment, } from './modules/service/index.js';
4
4
  export type { ArtifactModule, AssetBundle, DeployServiceInput, DeployServiceOutput, ResourceLimits, } from './modules/service/index.js';
5
5
  export { CloudflareWorker, OwnService, Service, TraverseOwnedService, service_owned_by, } from './modules/service/index.js';
6
+ export { CloudflareDeployment, Deployment, DeploymentLine, OwnDeployment, OwnLine, TraverseOwnedLine, TraverseOwnedLineDeployment, deploymentCommit, deploymentLifecycle, deploymentRecord, deploymentRetention, deploymentRetirement, deployment_on_line, line_owned_by, } from './modules/deployment/index.js';
7
+ export type { DeploymentLifecycle, DeploymentLifecycleEvent, DeploymentRecord, DeploymentRetention, DeploymentRetirement, } from './modules/deployment/index.js';
@@ -1,3 +1,4 @@
1
1
  export { schema } from './schema.js';
2
2
  export { artifact, artifactModule, assetBundle, assetFile, deployServiceInput, deployServiceOutput, resourceLimits, revisionedDeployment, } from './modules/service/index.js';
3
3
  export { CloudflareWorker, OwnService, Service, TraverseOwnedService, service_owned_by, } from './modules/service/index.js';
4
+ export { CloudflareDeployment, Deployment, DeploymentLine, OwnDeployment, OwnLine, TraverseOwnedLine, TraverseOwnedLineDeployment, deploymentCommit, deploymentLifecycle, deploymentRecord, deploymentRetention, deploymentRetirement, deployment_on_line, line_owned_by, } from './modules/deployment/index.js';
@@ -0,0 +1,97 @@
1
+ export declare const CloudflareDeployment: import("@astrale-os/sdk/schema").AuthoredNodeClass<{
2
+ readonly description: "One Deployment uploaded as the Workers for Platforms script its label names.";
3
+ readonly icon: import("@astrale-os/sdk/schema").ClassIcon;
4
+ readonly extends: readonly [import("@astrale-os/sdk/schema").AuthoredNodeClass<{
5
+ readonly abstract: true;
6
+ readonly description: "One immutable deployment of a release on a publisher line.";
7
+ readonly icon: import("@astrale-os/sdk/schema").ClassIcon;
8
+ readonly extends: readonly [import("@astrale-os/sdk/schema").KernelClass<"Timestamped">];
9
+ readonly properties: {
10
+ label: import("zod").ZodString;
11
+ url: import("zod").ZodString;
12
+ state: import("@astrale-os/sdk/schema").StateProperty<import("@astrale-os/sdk/schema").StateMachine<{
13
+ readonly deploying: {
14
+ readonly activate: "active";
15
+ readonly fail: "failed";
16
+ };
17
+ readonly active: {
18
+ readonly retire: "retiring";
19
+ };
20
+ readonly retiring: {
21
+ readonly completeRetirement: "retired";
22
+ };
23
+ readonly retired: {
24
+ readonly revive: "deploying";
25
+ };
26
+ readonly failed: {
27
+ readonly retry: "deploying";
28
+ };
29
+ }, "deploying">, {
30
+ required?: boolean;
31
+ description?: string;
32
+ }>;
33
+ retention: import("zod").ZodEnum<{
34
+ preview: "preview";
35
+ published: "published";
36
+ }>;
37
+ record: import("zod").ZodObject<{
38
+ format: import("zod").ZodLiteral<"astrale.deployment-record">;
39
+ version: import("zod").ZodLiteral<1>;
40
+ origin: import("zod").ZodString;
41
+ environment: import("zod").ZodString;
42
+ releaseDigest: import("zod").ZodString;
43
+ buildDigest: import("zod").ZodString;
44
+ schemaRevision: import("zod").ZodString;
45
+ configuration: import("zod").ZodString;
46
+ providerScript: import("zod").ZodString;
47
+ commit: import("zod").ZodNullable<import("zod").ZodObject<{
48
+ sha: import("zod").ZodString;
49
+ dirty: import("zod").ZodBoolean;
50
+ base: import("zod").ZodOptional<import("zod").ZodObject<{
51
+ version: import("zod").ZodString;
52
+ distance: import("zod").ZodNumber;
53
+ }, import("zod/v4/core").$strict>>;
54
+ }, import("zod/v4/core").$strict>>;
55
+ }, import("zod/v4/core").$strict>;
56
+ signingKeyId: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
57
+ readonly required: false;
58
+ }>;
59
+ lastCallAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
60
+ readonly type: 'string';
61
+ readonly 'x-astrale-value-kind': 'instant';
62
+ }>, {
63
+ readonly required: false;
64
+ }>;
65
+ expiresAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
66
+ readonly type: 'string';
67
+ readonly 'x-astrale-value-kind': 'instant';
68
+ }>, {
69
+ readonly required: false;
70
+ }>;
71
+ retiredAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
72
+ readonly type: 'string';
73
+ readonly 'x-astrale-value-kind': 'instant';
74
+ }>, {
75
+ readonly required: false;
76
+ }>;
77
+ retirement: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodEnum<{
78
+ expired: "expired";
79
+ withdrawn: "withdrawn";
80
+ }>, {
81
+ readonly required: false;
82
+ }>;
83
+ operationReceipt: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
84
+ readonly required: false;
85
+ }>;
86
+ operationExpiresAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
87
+ readonly type: 'string';
88
+ readonly 'x-astrale-value-kind': 'instant';
89
+ }>, {
90
+ readonly required: false;
91
+ }>;
92
+ };
93
+ }>];
94
+ readonly policies: {
95
+ read: () => import("@astrale-os/sdk/schema").Policy;
96
+ };
97
+ }>;
@@ -0,0 +1,10 @@
1
+ import { classIcon, nodeClass } from '@astrale-os/sdk/schema';
2
+ import { CloudUpload } from '@astrale-os/sdk/schema/icons';
3
+ import { OwnDeployment } from '../policies/own-deployment.js';
4
+ import { Deployment } from './deployment.js';
5
+ export const CloudflareDeployment = nodeClass({
6
+ description: 'One Deployment uploaded as the Workers for Platforms script its label names.',
7
+ icon: classIcon.lucide(CloudUpload),
8
+ extends: [Deployment],
9
+ policies: { read: () => OwnDeployment },
10
+ });