@astrale-domains/services 0.4.4 → 0.4.5

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
@@ -16,6 +16,9 @@ protocols behind neutral SDK Integrations and stores only admitted lifecycle evi
16
16
  - logs are remote evidence admitted by the Cloudflare Provider before a Workflow returns them.
17
17
  - the only admitted Worker service binding is the logical platform router; the provider resolves
18
18
  it to the selected environment and callers cannot name arbitrary same-account Workers.
19
+ - tenant Workers do not receive namespace dispatch authority. Inter-Service requests use public
20
+ ingress, including its retirement and private-route checks. Only the platform dispatcher and
21
+ candidate-readiness operator retain their respective native dispatch bindings.
19
22
  - the provider supplies `WORKER_URL` as the Service's canonical HTTPS URL to both candidate and
20
23
  public Workers. It is reserved provider metadata, not a caller variable or secret. Applications
21
24
  therefore keep one issuer even when requests arrive over HTTP or through a dispatcher alias.
@@ -27,12 +30,80 @@ are authorized by the ownership Policy before their Workflows begin. `service-ho
27
30
  is the provider-neutral compute Integration contract. Cloudflare is its first Provider.
28
31
 
29
32
  `Service` declares seven abstract management contracts. `CloudflareWorker` owns their executable
30
- Methods (`setSecret`, `deleteSecret`, `secrets`, `setSchedule`, `schedules`, `logs`, `delete`) and
33
+ Methods (`setSecret`, `deleteSecret`, `secrets`, `setSchedule`, `schedules`, `logs`, `migrateIngress`, `delete`) and
31
34
  applies `OwnService` to each one. Workflow addresses are `CloudflareWorker.*`; abstract `Service.*`
32
35
  keys are not executable. A future provider Class must supply its own concrete declarations and
33
36
  implementations. Properties and ownership relationships retain their inherited meaning.
34
37
 
35
- ## Development
38
+ ## Shared public ingress
39
+
40
+ New Services use the environment's proxied wildcard DNS, wildcard Worker route and wildcard
41
+ certificate. Services observes the expected dispatcher route and probes a never-registered hostname
42
+ before uploading a Worker without an existing Custom Domain. It never creates a per-Service Custom
43
+ Domain or certificate. Missing shared ingress is an operator provisioning issue; it is not repaired
44
+ by consuming another certificate during a Service deployment.
45
+
46
+ After activating routing, deployment waits for public dispatcher visibility with a bounded budget.
47
+ This is separate from optional candidate application health checks. An uncertain activation retains
48
+ the Service record and can be retried with the same owner-scoped service key.
49
+
50
+ Existing exact Custom Domains and their immutable certificate locators are retained on ordinary
51
+ updates. Detaching old hostnames is a separate migration, coordinated with Service operations;
52
+ upgrading this Runtime does not perform that migration. A Service without an exact domain persists
53
+ `tenantDomainState: 'absent'`.
54
+ Deletion handles both placements and never removes shared ingress infrastructure.
55
+
56
+ Cloudflare callers that must traverse same-zone public Worker routes need
57
+ `global_fetch_strictly_public`. Audit existing callers before migrating their exact hostnames;
58
+ explicit runtime flags retain their authored meaning. A rollback after creating wildcard Services
59
+ must use a Runtime that understands explicit absent placement, not an older certificate-required
60
+ implementation.
61
+
62
+ ### Migrating existing Services
63
+
64
+ The owner calls `CloudflareWorker.migrateIngress` with an `action` (`detach`, `complete`, or
65
+ `restore`) and a deterministic public health `probe`: a local GET `path`, expected successful
66
+ `status`, and the exact response body's SHA-256 in `bodySha256`. The Workflow acquires the existing
67
+ Service operation lease and verifies provider ownership, active dispatch, wildcard TLS and health
68
+ before detaching the exact Custom Domain. It does not redeploy the Worker or alter its routing KV,
69
+ secrets or schedules.
70
+
71
+ After `detach`, observe real traffic for at least 30 minutes before calling `complete`. Until
72
+ completion, the graph retains the previous domain and certificate locators with
73
+ `tenantDomainDetachedAt`; completion clears them after provider cleanup and another successful
74
+ health check. Cloudflare can automatically delete the generated certificate as soon as its Custom
75
+ Domain is detached. `restore` recreates the exact domain, waits for active TLS and records the actual
76
+ replacement certificate locator. A `pending` result requires a later retry; it is not completion.
77
+
78
+ The beta replacement serves at `https://services-v2.beta.astrale.ai` with the retained
79
+ `https://services.beta.astrale.ai` issuer and signing key. Keep the original runtime and identity
80
+ discovery available for existing installations. Adopt the replacement's public URL per instance
81
+ before invoking its additive migration API; do not uninstall Services or replace the shared R1
82
+ runtime underneath installations that still use its Schema. An instance rollback must restore any
83
+ detached domain before reinstalling the original endpoint, and must first be qualified with real
84
+ retained Service data.
85
+
86
+ ## Plain background Service starter
87
+
88
+ The package also ships an isolated scaffolding binary (the root import remains Schema-only):
89
+
90
+ ```sh
91
+ npm exec --package @astrale-domains/services -- create-astrale-service my-worker
92
+ cd my-worker
93
+ npm install
94
+ # Edit service.json and src/job.js for the existing business Domain.
95
+ npm run deploy -- --instance development --as developer
96
+ ```
97
+
98
+ The generator creates local source only and refuses an existing destination. It does not install
99
+ dependencies, create/register accounts, grant permissions, set secrets, enable schedules or deploy.
100
+ The generated project owns a plain HTTP/cron entry, an ES256 account session, a job implementation
101
+ and build/deploy scripts. No Application, Schema, Publication, Domain signer or frontend is generated.
102
+ Deployment calls the public Services API through the installed Astrale CLI and requires an explicit
103
+ instance or URL. The project README explains account setup, secret custody, private cron and cleanup.
104
+ No tooling dependency is added to the Schema runtime or imported from the package root.
105
+
106
+ ## Domain development
36
107
 
37
108
  Copy `.env.example` to `.env.dev` and fill the Cloudflare values.
38
109
 
@@ -58,6 +129,18 @@ unique and secrets use environment-variable identifiers. Plain and revisioned ar
58
129
  distinct modes and cannot replace each other at one Service record. Five-field cron expressions are
59
130
  supported when `CLOUDFLARE_SCHEDULE_KV_ID` is configured.
60
131
 
132
+ Plain deployments that omit `compatibilityFlags` use `nodejs_compat`,
133
+ `nodejs_compat_populate_process_env`, and `global_fetch_strictly_public`. Public `fetch()` calls
134
+ therefore traverse Cloudflare's public routes, including same-zone Astrale endpoints, instead of
135
+ bypassing those routes for the zone's origin. This changes routing, not Kernel authorization.
136
+ An explicitly supplied flags array replaces these defaults, including `[]`; low-level callers
137
+ own that complete runtime configuration. Existing Workers change only on redeployment.
138
+
139
+ Revisioned deployments require explicit runtime configuration and upload it unchanged to both the
140
+ candidate and the serving Worker. Those exact flags participate in the deployment identity;
141
+ Services must not inject defaults after its computation. The Cloudflare adapter already includes
142
+ public-fetch routing in the generated configuration before building the artifact and its digest.
143
+
61
144
  SDK-authored Applications should deploy through `@astrale-os/adapter-astrale`, which projects the
62
145
  admitted Cloudflare artifact into the revisioned Services request and verifies the live Publication.
63
146
  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
+ }
@@ -10,9 +10,9 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
10
10
  readonly properties: {
11
11
  providerName: z.ZodString;
12
12
  serviceKey: z.ZodString;
13
- url: import("@astrale-os/sdk/schema").Property<z.ZodString, {
13
+ url: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
14
14
  readonly required: false;
15
- }, never>;
15
+ }>;
16
16
  state: import("@astrale-os/sdk/schema").StateProperty<import("@astrale-os/sdk/schema").StateMachine<{
17
17
  readonly deploying: {
18
18
  readonly recordProviderDeployment: "reconciling";
@@ -38,21 +38,21 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
38
38
  required?: boolean;
39
39
  description?: string;
40
40
  }>;
41
- digest: import("@astrale-os/sdk/schema").Property<z.ZodString, {
41
+ digest: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
42
42
  readonly required: false;
43
- }, never>;
44
- deploymentReceipt: import("@astrale-os/sdk/schema").Property<z.ZodString, {
43
+ }>;
44
+ deploymentReceipt: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
45
45
  readonly required: false;
46
- }, never>;
47
- operationExpiresAt: import("@astrale-os/sdk/schema").Property<Readonly<{
46
+ }>;
47
+ operationExpiresAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
48
48
  readonly type: 'string';
49
49
  readonly 'x-astrale-value-kind': 'instant';
50
50
  }>, {
51
51
  readonly required: false;
52
- }, never>;
53
- error: import("@astrale-os/sdk/schema").Property<z.ZodString, {
52
+ }>;
53
+ error: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
54
54
  readonly required: false;
55
- }, never>;
55
+ }>;
56
56
  };
57
57
  readonly methods: {
58
58
  setSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -61,10 +61,10 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
61
61
  }, {
62
62
  readonly mode: 'value';
63
63
  readonly value: {
64
- name: string;
65
- } | {
66
64
  kind: "rejected";
67
65
  reason: "binding-unavailable" | "invalid-secret-name" | "service-unavailable";
66
+ } | {
67
+ name: string;
68
68
  };
69
69
  }, "authorized", false, true>>;
70
70
  deleteSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -72,10 +72,10 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
72
72
  }, {
73
73
  readonly mode: 'value';
74
74
  readonly value: {
75
+ ok: true;
76
+ } | {
75
77
  kind: "rejected";
76
78
  reason: "binding-unavailable" | "invalid-secret-name" | "service-unavailable";
77
- } | {
78
- ok: true;
79
79
  };
80
80
  }, "authorized", false, true>>;
81
81
  secrets: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
@@ -125,22 +125,28 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
125
125
  plain: "plain";
126
126
  revisioned: "revisioned";
127
127
  }>;
128
- tenantDomainState: import("@astrale-os/sdk/schema").Property<z.ZodEnum<{
128
+ tenantDomainDetachedAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
129
+ readonly type: 'string';
130
+ readonly 'x-astrale-value-kind': 'instant';
131
+ }>, {
132
+ readonly required: false;
133
+ }>;
134
+ tenantDomainState: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodEnum<{
129
135
  absent: "absent";
130
136
  attached: "attached";
131
137
  unresolved: "unresolved";
132
138
  }>, {
133
139
  readonly required: false;
134
- }, never>;
135
- tenantDomainId: import("@astrale-os/sdk/schema").Property<z.ZodString, {
140
+ }>;
141
+ tenantDomainId: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
136
142
  readonly required: false;
137
- }, never>;
138
- tenantCertificatePackId: import("@astrale-os/sdk/schema").Property<z.ZodString, {
143
+ }>;
144
+ tenantCertificatePackId: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
139
145
  readonly required: false;
140
- }, never>;
141
- tenantZoneId: import("@astrale-os/sdk/schema").Property<z.ZodString, {
146
+ }>;
147
+ tenantZoneId: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
142
148
  readonly required: false;
143
- }, never>;
149
+ }>;
144
150
  };
145
151
  readonly methods: {
146
152
  deploy: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -216,16 +222,40 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
216
222
  message: string;
217
223
  };
218
224
  }, "authenticated", true, false>>;
225
+ migrateIngress: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
226
+ action: "complete" | "detach" | "restore";
227
+ probe: {
228
+ path: string;
229
+ status: number;
230
+ bodySha256: string;
231
+ };
232
+ }, {
233
+ readonly mode: 'value';
234
+ readonly value: {
235
+ kind: "detached";
236
+ detachedAt: string;
237
+ } | {
238
+ kind: "migrated";
239
+ } | {
240
+ kind: "restored";
241
+ } | {
242
+ kind: "pending";
243
+ retryAfterMs: number;
244
+ } | {
245
+ kind: "rejected";
246
+ reason: "missing-location" | "not-detached" | "service-unavailable";
247
+ };
248
+ }, "authorized", false, false>>;
219
249
  setSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
220
250
  name: string;
221
251
  value: string;
222
252
  }, {
223
253
  readonly mode: 'value';
224
254
  readonly value: {
225
- name: string;
226
- } | {
227
255
  kind: "rejected";
228
256
  reason: "binding-unavailable" | "invalid-secret-name" | "service-unavailable";
257
+ } | {
258
+ name: string;
229
259
  };
230
260
  }, "authorized", false, false>>;
231
261
  deleteSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -233,10 +263,10 @@ export declare const CloudflareWorker: import("@astrale-os/sdk/schema").Authored
233
263
  }, {
234
264
  readonly mode: 'value';
235
265
  readonly value: {
266
+ ok: true;
267
+ } | {
236
268
  kind: "rejected";
237
269
  reason: "binding-unavailable" | "invalid-secret-name" | "service-unavailable";
238
- } | {
239
- ok: true;
240
270
  };
241
271
  }, "authorized", false, false>>;
242
272
  secrets: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
@@ -1,4 +1,4 @@
1
- import { classIcon, nodeClass, property } from '@astrale-os/sdk/schema';
1
+ import { classIcon, nodeClass, property, schema as valueSchema } from '@astrale-os/sdk/schema';
2
2
  import { CloudCog } from '@astrale-os/sdk/schema/icons';
3
3
  import { z } from 'zod';
4
4
  import { deleteWorkerSecret } from '../functions/delete-worker-secret.js';
@@ -7,6 +7,7 @@ import { deployCloudflareWorker } from '../functions/deploy-cloudflare-worker.js
7
7
  import { listWorkerLogs } from '../functions/list-worker-logs.js';
8
8
  import { listWorkerSchedules } from '../functions/list-worker-schedules.js';
9
9
  import { listWorkerSecretNames } from '../functions/list-worker-secret-names.js';
10
+ import { migrateWorkerIngress } from '../functions/migrate-worker-ingress.js';
10
11
  import { replaceWorkerSchedules } from '../functions/replace-worker-schedules.js';
11
12
  import { setWorkerSecret } from '../functions/set-worker-secret.js';
12
13
  import { OwnService } from '../policies/own-service.js';
@@ -17,6 +18,7 @@ export const CloudflareWorker = nodeClass({
17
18
  extends: [Service],
18
19
  properties: {
19
20
  deploymentMode: z.enum(['plain', 'revisioned']),
21
+ tenantDomainDetachedAt: property(valueSchema.values.instant, { required: false }),
20
22
  tenantDomainState: property(z.enum(['unresolved', 'absent', 'attached']), {
21
23
  required: false,
22
24
  }),
@@ -26,6 +28,7 @@ export const CloudflareWorker = nodeClass({
26
28
  },
27
29
  methods: {
28
30
  deploy: deployCloudflareWorker,
31
+ migrateIngress: migrateWorkerIngress,
29
32
  setSecret: setWorkerSecret,
30
33
  deleteSecret: deleteWorkerSecret,
31
34
  secrets: listWorkerSecretNames,
@@ -8,9 +8,9 @@ export declare const Service: import("@astrale-os/sdk/schema").AuthoredNodeClass
8
8
  readonly properties: {
9
9
  providerName: z.ZodString;
10
10
  serviceKey: z.ZodString;
11
- url: import("@astrale-os/sdk/schema").Property<z.ZodString, {
11
+ url: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
12
12
  readonly required: false;
13
- }, never>;
13
+ }>;
14
14
  state: import("@astrale-os/sdk/schema").StateProperty<import("@astrale-os/sdk/schema").StateMachine<{
15
15
  readonly deploying: {
16
16
  readonly recordProviderDeployment: "reconciling";
@@ -36,21 +36,21 @@ export declare const Service: import("@astrale-os/sdk/schema").AuthoredNodeClass
36
36
  required?: boolean;
37
37
  description?: string;
38
38
  }>;
39
- digest: import("@astrale-os/sdk/schema").Property<z.ZodString, {
39
+ digest: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
40
40
  readonly required: false;
41
- }, never>;
42
- deploymentReceipt: import("@astrale-os/sdk/schema").Property<z.ZodString, {
41
+ }>;
42
+ deploymentReceipt: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
43
43
  readonly required: false;
44
- }, never>;
45
- operationExpiresAt: import("@astrale-os/sdk/schema").Property<Readonly<{
44
+ }>;
45
+ operationExpiresAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
46
46
  readonly type: 'string';
47
47
  readonly 'x-astrale-value-kind': 'instant';
48
48
  }>, {
49
49
  readonly required: false;
50
- }, never>;
51
- error: import("@astrale-os/sdk/schema").Property<z.ZodString, {
50
+ }>;
51
+ error: import("@astrale-os/sdk/schema").AuthoredProperty<z.ZodString, {
52
52
  readonly required: false;
53
- }, never>;
53
+ }>;
54
54
  };
55
55
  readonly methods: {
56
56
  setSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -1,3 +1,4 @@
1
+ export { migrateWorkerIngress } from './migrate-worker-ingress.js';
1
2
  export { deleteServiceSecret } from './delete-service-secret.js';
2
3
  export { deleteService } from './delete-service.js';
3
4
  export { deployCloudflareWorker } from './deploy-cloudflare-worker.js';
@@ -1,3 +1,4 @@
1
+ export { migrateWorkerIngress } from './migrate-worker-ingress.js';
1
2
  export { deleteServiceSecret } from './delete-service-secret.js';
2
3
  export { deleteService } from './delete-service.js';
3
4
  export { deployCloudflareWorker } from './deploy-cloudflare-worker.js';
@@ -0,0 +1,24 @@
1
+ export declare const migrateWorkerIngress: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
2
+ action: "complete" | "detach" | "restore";
3
+ probe: {
4
+ path: string;
5
+ status: number;
6
+ bodySha256: string;
7
+ };
8
+ }, {
9
+ readonly mode: 'value';
10
+ readonly value: {
11
+ kind: "detached";
12
+ detachedAt: string;
13
+ } | {
14
+ kind: "migrated";
15
+ } | {
16
+ kind: "restored";
17
+ } | {
18
+ kind: "pending";
19
+ retryAfterMs: number;
20
+ } | {
21
+ kind: "rejected";
22
+ reason: "missing-location" | "not-detached" | "service-unavailable";
23
+ };
24
+ }, "authorized", false, false>>;
@@ -0,0 +1,26 @@
1
+ import { method } from '@astrale-os/sdk/schema';
2
+ import { z } from 'zod';
3
+ import { OwnService } from '../policies/own-service.js';
4
+ export const migrateWorkerIngress = method({
5
+ description: 'Migrate legacy ingress to the shared wildcard, retaining cleanup evidence until verified; optionally restore the domain before finalization.',
6
+ auth: 'authorized',
7
+ input: z.strictObject({
8
+ action: z.enum(['detach', 'complete', 'restore']),
9
+ probe: z.strictObject({
10
+ path: z.string().min(1).max(1024),
11
+ status: z.number().int().min(200).max(299),
12
+ bodySha256: z.string().min(64).max(64),
13
+ }),
14
+ }),
15
+ output: z.discriminatedUnion('kind', [
16
+ z.strictObject({ kind: z.literal('detached'), detachedAt: z.string().min(20).max(30) }),
17
+ z.strictObject({ kind: z.literal('migrated') }),
18
+ z.strictObject({ kind: z.literal('restored') }),
19
+ z.strictObject({ kind: z.literal('pending'), retryAfterMs: z.number().int().positive() }),
20
+ z.strictObject({
21
+ kind: z.literal('rejected'),
22
+ reason: z.enum(['service-unavailable', 'missing-location', 'not-detached']),
23
+ }),
24
+ ]),
25
+ policy: ({ check, self }) => check(OwnService, self),
26
+ });
@@ -4,9 +4,9 @@ export declare const setWorkerSecret: import("@astrale-os/sdk/schema").Method<im
4
4
  }, {
5
5
  readonly mode: 'value';
6
6
  readonly value: {
7
+ name: string;
8
+ } | {
7
9
  kind: "rejected";
8
10
  reason: "binding-unavailable" | "invalid-secret-name" | "service-unavailable";
9
- } | {
10
- name: string;
11
11
  };
12
12
  }, "authorized", false, false>>;
@@ -10,9 +10,9 @@ export declare const schema: import("@astrale-os/sdk/schema").AuthoredSchema<"se
10
10
  readonly properties: {
11
11
  providerName: import("zod").ZodString;
12
12
  serviceKey: import("zod").ZodString;
13
- url: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
13
+ url: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
14
14
  readonly required: false;
15
- }, never>;
15
+ }>;
16
16
  state: import("@astrale-os/sdk/schema").StateProperty<import("@astrale-os/sdk/schema").StateMachine<{
17
17
  readonly deploying: {
18
18
  readonly recordProviderDeployment: "reconciling";
@@ -38,21 +38,21 @@ export declare const schema: import("@astrale-os/sdk/schema").AuthoredSchema<"se
38
38
  required?: boolean;
39
39
  description?: string;
40
40
  }>;
41
- digest: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
41
+ digest: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
42
42
  readonly required: false;
43
- }, never>;
44
- deploymentReceipt: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
43
+ }>;
44
+ deploymentReceipt: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
45
45
  readonly required: false;
46
- }, never>;
47
- operationExpiresAt: import("@astrale-os/sdk/schema").Property<Readonly<{
46
+ }>;
47
+ operationExpiresAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
48
48
  readonly type: 'string';
49
49
  readonly 'x-astrale-value-kind': 'instant';
50
50
  }>, {
51
51
  readonly required: false;
52
- }, never>;
53
- error: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
52
+ }>;
53
+ error: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
54
54
  readonly required: false;
55
- }, never>;
55
+ }>;
56
56
  };
57
57
  readonly methods: {
58
58
  setSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -131,9 +131,9 @@ export declare const schema: import("@astrale-os/sdk/schema").AuthoredSchema<"se
131
131
  readonly properties: {
132
132
  providerName: import("zod").ZodString;
133
133
  serviceKey: import("zod").ZodString;
134
- url: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
134
+ url: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
135
135
  readonly required: false;
136
- }, never>;
136
+ }>;
137
137
  state: import("@astrale-os/sdk/schema").StateProperty<import("@astrale-os/sdk/schema").StateMachine<{
138
138
  readonly deploying: {
139
139
  readonly recordProviderDeployment: "reconciling";
@@ -159,21 +159,21 @@ export declare const schema: import("@astrale-os/sdk/schema").AuthoredSchema<"se
159
159
  required?: boolean;
160
160
  description?: string;
161
161
  }>;
162
- digest: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
162
+ digest: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
163
163
  readonly required: false;
164
- }, never>;
165
- deploymentReceipt: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
164
+ }>;
165
+ deploymentReceipt: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
166
166
  readonly required: false;
167
- }, never>;
168
- operationExpiresAt: import("@astrale-os/sdk/schema").Property<Readonly<{
167
+ }>;
168
+ operationExpiresAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
169
169
  readonly type: 'string';
170
170
  readonly 'x-astrale-value-kind': 'instant';
171
171
  }>, {
172
172
  readonly required: false;
173
- }, never>;
174
- error: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
173
+ }>;
174
+ error: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
175
175
  readonly required: false;
176
- }, never>;
176
+ }>;
177
177
  };
178
178
  readonly methods: {
179
179
  setSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -246,22 +246,28 @@ export declare const schema: import("@astrale-os/sdk/schema").AuthoredSchema<"se
246
246
  plain: "plain";
247
247
  revisioned: "revisioned";
248
248
  }>;
249
- tenantDomainState: import("@astrale-os/sdk/schema").Property<import("zod").ZodEnum<{
249
+ tenantDomainDetachedAt: import("@astrale-os/sdk/schema").AuthoredProperty<Readonly<{
250
+ readonly type: 'string';
251
+ readonly 'x-astrale-value-kind': 'instant';
252
+ }>, {
253
+ readonly required: false;
254
+ }>;
255
+ tenantDomainState: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodEnum<{
250
256
  absent: "absent";
251
257
  attached: "attached";
252
258
  unresolved: "unresolved";
253
259
  }>, {
254
260
  readonly required: false;
255
- }, never>;
256
- tenantDomainId: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
261
+ }>;
262
+ tenantDomainId: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
257
263
  readonly required: false;
258
- }, never>;
259
- tenantCertificatePackId: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
264
+ }>;
265
+ tenantCertificatePackId: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
260
266
  readonly required: false;
261
- }, never>;
262
- tenantZoneId: import("@astrale-os/sdk/schema").Property<import("zod").ZodString, {
267
+ }>;
268
+ tenantZoneId: import("@astrale-os/sdk/schema").AuthoredProperty<import("zod").ZodString, {
263
269
  readonly required: false;
264
- }, never>;
270
+ }>;
265
271
  };
266
272
  readonly methods: {
267
273
  deploy: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -337,6 +343,30 @@ export declare const schema: import("@astrale-os/sdk/schema").AuthoredSchema<"se
337
343
  message: string;
338
344
  };
339
345
  }, "authenticated", true, false>>;
346
+ migrateIngress: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
347
+ action: "complete" | "detach" | "restore";
348
+ probe: {
349
+ path: string;
350
+ status: number;
351
+ bodySha256: string;
352
+ };
353
+ }, {
354
+ readonly mode: 'value';
355
+ readonly value: {
356
+ kind: "detached";
357
+ detachedAt: string;
358
+ } | {
359
+ kind: "migrated";
360
+ } | {
361
+ kind: "restored";
362
+ } | {
363
+ kind: "pending";
364
+ retryAfterMs: number;
365
+ } | {
366
+ kind: "rejected";
367
+ reason: "missing-location" | "not-detached" | "service-unavailable";
368
+ };
369
+ }, "authorized", false, false>>;
340
370
  setSecret: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
341
371
  name: string;
342
372
  value: string;
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@astrale-domains/services",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/astrale-os/domains.git",
7
7
  "directory": "services"
8
8
  },
9
+ "bin": {
10
+ "create-astrale-service": "./dist/scaffold/create-service.mjs"
11
+ },
9
12
  "files": [
10
13
  "dist"
11
14
  ],
@@ -28,11 +31,11 @@
28
31
  "registry": "https://registry.npmjs.org/"
29
32
  },
30
33
  "dependencies": {
31
- "@astrale-os/sdk": "0.5.0-beta.131",
34
+ "@astrale-os/sdk": "0.5.0-beta.144",
32
35
  "zod": "4.4.3"
33
36
  },
34
37
  "devDependencies": {
35
- "@astrale-os/adapter-cloudflare": "0.5.0-beta.134",
38
+ "@astrale-os/adapter-cloudflare": "0.5.0-beta.147",
36
39
  "@astrale-os/ox": ">=0.1.4 <1.0.0",
37
40
  "@astrale-os/shell": "0.5.0-beta.18",
38
41
  "@astrale-os/shell-react": "0.4.0-beta.17",
@@ -75,7 +78,7 @@
75
78
  "build": "astrale-domain build",
76
79
  "lint": "astrale-domain lint",
77
80
  "lint:fix": "astrale-domain lint --fix",
78
- "package": "astrale-domain package",
81
+ "package": "astrale-domain package && node tooling/package-scaffolder.mjs",
79
82
  "provider:bootstrap": "bun providers/cloudflare/operator/provision/bootstrap.ts",
80
83
  "provider:doctor": "bun providers/cloudflare/operator/maintenance/doctor.ts",
81
84
  "provider:orphan-audit": "bun providers/cloudflare/operator/maintenance/orphan-audit.ts",