@bakers-build/addon-ports-cloudflare 0.0.0-stage → 0.1.0-next.38

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-ports-cloudflare
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build ports transport: a named Cloudflare Tunnel, reconciled via the Cloudflare API (DNS + ingress). Implements @bakers-build/addon-ports's PortsTransport contract.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,57 @@
1
+ export interface CloudflareConfig {
2
+ readonly accountId: string;
3
+ readonly tunnelId: string;
4
+ readonly zoneId: string;
5
+ /** The bare zone hostname (e.g. `preview.example`) — carried here so
6
+ * `index.ts` can surface it as `PortsTransport.zone` for hostname
7
+ * construction; `cloudflare.ts`'s own API calls use `zoneId`, not this. */
8
+ readonly zone: string;
9
+ readonly apiToken: string;
10
+ }
11
+ export interface CfOriginRequest {
12
+ readonly httpHostHeader?: string;
13
+ readonly noTLSVerify?: boolean;
14
+ }
15
+ export interface CfIngressRule {
16
+ readonly hostname?: string;
17
+ readonly service: string;
18
+ readonly originRequest?: CfOriginRequest;
19
+ }
20
+ export interface CfFetchResponse {
21
+ readonly ok: boolean;
22
+ readonly status: number;
23
+ json(): Promise<unknown>;
24
+ }
25
+ /** The HTTP seam, injected — a test fakes it, nothing here calls real `fetch`. */
26
+ export interface CloudflareDeps {
27
+ fetch(url: string, init: {
28
+ readonly method: string;
29
+ readonly headers: Readonly<Record<string, string>>;
30
+ readonly body?: string;
31
+ readonly signal?: AbortSignal;
32
+ }): Promise<CfFetchResponse>;
33
+ readonly timeoutMs: number;
34
+ }
35
+ export declare class CloudflareApiError extends Error {
36
+ constructor(message: string);
37
+ }
38
+ /** The tunnel's currently uploaded ingress — read before replacing it, so an
39
+ * unexpected unmanaged rule set can block the replace rather than vanish. */
40
+ export declare function getIngressConfig(cfg: CloudflareConfig, deps: CloudflareDeps): Promise<{
41
+ readonly ingress: readonly CfIngressRule[];
42
+ }>;
43
+ /** Replace the WHOLE ingress list — Cloudflare's configuration API has no
44
+ * partial update, so every call uploads the complete desired set. */
45
+ export declare function putIngressConfig(cfg: CloudflareConfig, ingress: readonly CfIngressRule[], deps: CloudflareDeps): Promise<void>;
46
+ export interface CfDnsRecord {
47
+ readonly id: string;
48
+ readonly content: string;
49
+ }
50
+ /** The exact-name CNAME, if any — used both to detect a foreign-owned
51
+ * conflict and to adopt a record this tunnel already created. */
52
+ export declare function findDnsRecord(cfg: CloudflareConfig, hostname: string, deps: CloudflareDeps): Promise<CfDnsRecord | null>;
53
+ /** This tunnel's own target for a proxied CNAME — the only content a record
54
+ * this reconciliation may delete is allowed to carry (ownership check). */
55
+ export declare function tunnelCnameTarget(cfg: CloudflareConfig): string;
56
+ export declare function createDnsRecord(cfg: CloudflareConfig, hostname: string, deps: CloudflareDeps): Promise<CfDnsRecord>;
57
+ export declare function deleteDnsRecord(cfg: CloudflareConfig, recordId: string, deps: CloudflareDeps): Promise<void>;
@@ -0,0 +1,92 @@
1
+ // BB-676 Task 3, moved to its own package by BB-681 Task 2 — the
2
+ // Cloudflare REST calls a reconciliation needs: upload the tunnel's ingress
3
+ // configuration and own the proxied CNAME per hostname. See
4
+ // https://developers.cloudflare.com/api/resources/zero_trust/subresources/tunnels/subresources/cloudflared/subresources/configurations/
5
+ // and https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel-api/
6
+ // for the API shapes this wraps; locking/ownership/retry semantics are this
7
+ // ticket's own decisions, not the API's.
8
+ export class CloudflareApiError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'CloudflareApiError';
12
+ }
13
+ }
14
+ /** Replace the token with a fixed marker wherever it would otherwise leak
15
+ * into a thrown message, a log line or a CLI argument. */
16
+ function redact(token, message) {
17
+ return token ? message.split(token).join('[REDACTED]') : message;
18
+ }
19
+ async function call(cfg, deps, path, init) {
20
+ const controller = new AbortController();
21
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
22
+ try {
23
+ let res;
24
+ try {
25
+ res = await deps.fetch(`https://api.cloudflare.com/client/v4${path}`, {
26
+ method: init.method,
27
+ headers: {
28
+ authorization: `Bearer ${cfg.apiToken}`,
29
+ 'content-type': 'application/json',
30
+ },
31
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
32
+ signal: controller.signal,
33
+ });
34
+ }
35
+ catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ throw new CloudflareApiError(redact(cfg.apiToken, `${init.method} ${path} failed: ${msg}`));
38
+ }
39
+ const body = (await res.json().catch(() => ({})));
40
+ if (!res.ok || body.success === false) {
41
+ const detail = (body.errors ?? []).map((e) => e.message).join('; ') ||
42
+ `HTTP ${res.status}`;
43
+ throw new CloudflareApiError(redact(cfg.apiToken, `${init.method} ${path} failed: ${detail}`));
44
+ }
45
+ return body.result;
46
+ }
47
+ finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+ /** The tunnel's currently uploaded ingress — read before replacing it, so an
52
+ * unexpected unmanaged rule set can block the replace rather than vanish. */
53
+ export async function getIngressConfig(cfg, deps) {
54
+ const result = await call(cfg, deps, `/accounts/${cfg.accountId}/cfd_tunnel/${cfg.tunnelId}/configurations`, { method: 'GET' });
55
+ const config = result
56
+ ?.config;
57
+ return { ingress: config?.ingress ?? [] };
58
+ }
59
+ /** Replace the WHOLE ingress list — Cloudflare's configuration API has no
60
+ * partial update, so every call uploads the complete desired set. */
61
+ export async function putIngressConfig(cfg, ingress, deps) {
62
+ await call(cfg, deps, `/accounts/${cfg.accountId}/cfd_tunnel/${cfg.tunnelId}/configurations`, { method: 'PUT', body: { config: { ingress } } });
63
+ }
64
+ /** The exact-name CNAME, if any — used both to detect a foreign-owned
65
+ * conflict and to adopt a record this tunnel already created. */
66
+ export async function findDnsRecord(cfg, hostname, deps) {
67
+ const result = await call(cfg, deps, `/zones/${cfg.zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(hostname)}`, { method: 'GET' });
68
+ const records = result ?? [];
69
+ return records[0] ?? null;
70
+ }
71
+ /** This tunnel's own target for a proxied CNAME — the only content a record
72
+ * this reconciliation may delete is allowed to carry (ownership check). */
73
+ export function tunnelCnameTarget(cfg) {
74
+ return `${cfg.tunnelId}.cfargotunnel.com`;
75
+ }
76
+ export async function createDnsRecord(cfg, hostname, deps) {
77
+ const result = await call(cfg, deps, `/zones/${cfg.zoneId}/dns_records`, {
78
+ method: 'POST',
79
+ body: {
80
+ type: 'CNAME',
81
+ name: hostname,
82
+ content: tunnelCnameTarget(cfg),
83
+ proxied: true,
84
+ },
85
+ });
86
+ return result;
87
+ }
88
+ export async function deleteDnsRecord(cfg, recordId, deps) {
89
+ await call(cfg, deps, `/zones/${cfg.zoneId}/dns_records/${recordId}`, {
90
+ method: 'DELETE',
91
+ });
92
+ }
@@ -0,0 +1,19 @@
1
+ import type { PortsTransport } from '@bakers-build/addon-ports/transport';
2
+ export type { CloudflareConfig, CloudflareDeps } from './cloudflare.js';
3
+ export { reconcileCloudflare } from './reconcile.js';
4
+ /** The `@bakers-build/addon-ports` entry's `with.transport.with` for this transport. */
5
+ export interface CloudflareSettings {
6
+ readonly accountId: string;
7
+ readonly tunnelId: string;
8
+ readonly zoneId: string;
9
+ readonly zone: string;
10
+ readonly apiToken: string;
11
+ }
12
+ /**
13
+ * Default export factory consumed by `@bakers-build/addon-ports`'s
14
+ * `loadPortsTransport`. `identity` deliberately excludes `apiToken` — a
15
+ * rotated token with the same account/tunnel/zone is NOT an identity change.
16
+ */
17
+ export default function createTransport(settings: unknown, _context: {
18
+ readonly home: string;
19
+ }): Promise<PortsTransport>;
@@ -0,0 +1,69 @@
1
+ // BB-681 Task 2 — Named Cloudflare as a `PortsTransport`. The connector
2
+ // (`cloudflared` daemon) is operator-managed: this package never starts or
3
+ // stops it, only reconciles DNS + ingress via the Cloudflare REST API.
4
+ import { reconcileCloudflare } from './reconcile.js';
5
+ export { reconcileCloudflare } from './reconcile.js';
6
+ const REQUIRED_FIELDS = [
7
+ 'accountId',
8
+ 'tunnelId',
9
+ 'zoneId',
10
+ 'zone',
11
+ 'apiToken',
12
+ ];
13
+ function validateSettings(settings) {
14
+ if (typeof settings !== 'object' || settings === null) {
15
+ throw new Error("the @bakers-build/addon-ports entry's with.transport.with for 'cloudflare' must be an object with accountId/tunnelId/zoneId/zone/apiToken");
16
+ }
17
+ const obj = settings;
18
+ const missing = REQUIRED_FIELDS.filter((f) => typeof obj[f] !== 'string' || obj[f] === '');
19
+ if (missing.length > 0) {
20
+ throw new Error(`the @bakers-build/addon-ports entry's with.transport.with for 'cloudflare' is missing: ${missing.join(', ')}`);
21
+ }
22
+ return {
23
+ accountId: obj.accountId,
24
+ tunnelId: obj.tunnelId,
25
+ zoneId: obj.zoneId,
26
+ zone: obj.zone,
27
+ apiToken: obj.apiToken,
28
+ };
29
+ }
30
+ /** Real `fetch` — the only seam into the actual network, injected into
31
+ * `reconcileCloudflare`/`cloudflare.ts`'s calls, never used directly by the
32
+ * generic ports layer. */
33
+ const nodeCloudflareDeps = {
34
+ fetch: async (url, init) => {
35
+ const res = await fetch(url, init);
36
+ return { ok: res.ok, status: res.status, json: () => res.json() };
37
+ },
38
+ timeoutMs: 10_000,
39
+ };
40
+ /** Allowlisted provider state for this transport: only a DNS record id. */
41
+ function validateData(data) {
42
+ if (typeof data !== 'object' || data === null) {
43
+ throw new Error('cloudflare transport data must be an object');
44
+ }
45
+ const obj = data;
46
+ const keys = Object.keys(obj);
47
+ if (keys.some((k) => k !== 'dnsRecordId')) {
48
+ throw new Error(`cloudflare transport data has unknown field(s): ${keys.filter((k) => k !== 'dnsRecordId').join(', ')}`);
49
+ }
50
+ if (obj.dnsRecordId !== undefined && typeof obj.dnsRecordId !== 'string') {
51
+ throw new Error('cloudflare transport data.dnsRecordId must be a string');
52
+ }
53
+ }
54
+ /**
55
+ * Default export factory consumed by `@bakers-build/addon-ports`'s
56
+ * `loadPortsTransport`. `identity` deliberately excludes `apiToken` — a
57
+ * rotated token with the same account/tunnel/zone is NOT an identity change.
58
+ */
59
+ export default async function createTransport(settings, _context) {
60
+ const cfg = validateSettings(settings);
61
+ const deps = nodeCloudflareDeps;
62
+ return {
63
+ id: 'cloudflare',
64
+ identity: `${cfg.accountId}/${cfg.tunnelId}/${cfg.zoneId}`,
65
+ zone: cfg.zone,
66
+ validateData,
67
+ reconcile: (records, { checkpoint }) => reconcileCloudflare(records, cfg, deps, { checkpoint }),
68
+ };
69
+ }
@@ -0,0 +1,10 @@
1
+ import type { TunnelDesiredRecord } from '@bakers-build/addon-ports/transport';
2
+ import { type CloudflareConfig, type CloudflareDeps } from './cloudflare.js';
3
+ export interface ReconcileContext {
4
+ readonly signal?: AbortSignal;
5
+ checkpoint(records: readonly TunnelDesiredRecord[]): Promise<void>;
6
+ }
7
+ export declare function reconcileCloudflare(records: readonly TunnelDesiredRecord[], cfg: CloudflareConfig, deps: CloudflareDeps, context: ReconcileContext): Promise<{
8
+ readonly records: readonly TunnelDesiredRecord[];
9
+ readonly ok: boolean;
10
+ }>;
@@ -0,0 +1,200 @@
1
+ // BB-676 Task 3, generalized by BB-681 Task 2 into this package's own
2
+ // `PortsTransport.reconcile` — DNS create/adopt + the complete ingress
3
+ // upload, in the order a crash can recover from: an active record without
4
+ // an owned CNAME gets one BEFORE the ingress that names its hostname is
5
+ // uploaded (so a hostname never resolves before its route exists); a
6
+ // reduced ingress is uploaded BEFORE an absent record's CNAME is deleted
7
+ // (so the public URL stops routing before the DNS name that reached it
8
+ // disappears). Returns the records with updated `applied`/`data.dnsRecordId`
9
+ // /`error` — the generic layer (`tunnel-ops.ts`) persists them.
10
+ import { upsertRecord } from '@bakers-build/addon-ports/transport';
11
+ import { createDnsRecord, deleteDnsRecord, findDnsRecord, getIngressConfig, putIngressConfig, tunnelCnameTarget, } from './cloudflare.js';
12
+ function dnsRecordId(record) {
13
+ const v = record.data?.dnsRecordId;
14
+ return typeof v === 'string' ? v : undefined;
15
+ }
16
+ function withDnsRecordId(record, id) {
17
+ return {
18
+ ...record,
19
+ data: id === undefined ? undefined : { dnsRecordId: id },
20
+ };
21
+ }
22
+ export async function reconcileCloudflare(records, cfg, deps, context) {
23
+ let next = records;
24
+ let ok = true;
25
+ const active = next.filter((r) => r.desired === 'present');
26
+ // Records whose DNS create/adopt itself failed (e.g. a foreign-owned exact
27
+ // name): they still ride along in the ingress upload below so one bad
28
+ // record can't drop its siblings, but they must not be reported `applied`
29
+ // just because the PUT happened to succeed — there's no DNS pointing at
30
+ // them. Track their ids to preserve that `error` through the PUT phase.
31
+ const dnsErrorIds = new Set();
32
+ for (const record of active) {
33
+ if (dnsRecordId(record))
34
+ continue;
35
+ try {
36
+ const existing = await findDnsRecord(cfg, record.hostname, deps);
37
+ if (existing && existing.content !== tunnelCnameTarget(cfg)) {
38
+ throw new Error(`${record.hostname} already has a DNS record this tunnel does not own`);
39
+ }
40
+ let id = existing?.id;
41
+ if (id === undefined) {
42
+ try {
43
+ id = (await createDnsRecord(cfg, record.hostname, deps)).id;
44
+ }
45
+ catch (err) {
46
+ // Uncertain create (e.g. the request succeeded remotely but the
47
+ // response was ambiguous, or this is a retry after a timeout):
48
+ // reconcile against the live state by exact hostname+content match
49
+ // rather than assuming failure (which would otherwise risk a
50
+ // duplicate record on the next attempt).
51
+ const recheck = await findDnsRecord(cfg, record.hostname, deps).catch(() => null);
52
+ if (recheck && recheck.content === tunnelCnameTarget(cfg)) {
53
+ id = recheck.id;
54
+ }
55
+ else {
56
+ throw err;
57
+ }
58
+ }
59
+ }
60
+ next = upsertRecord(next, withDnsRecordId({ ...record, applied: 'pending', error: undefined }, id));
61
+ // Checkpoint DNS ownership immediately after acquiring/confirming it —
62
+ // don't wait until the whole reconcile finishes to record that this
63
+ // process now owns a DNS record (so a crash before the ingress PUT
64
+ // still leaves ownership discoverable on the next reconcile).
65
+ await context.checkpoint(next);
66
+ }
67
+ catch (err) {
68
+ ok = false;
69
+ dnsErrorIds.add(`${record.worktree}:${record.key}`);
70
+ next = upsertRecord(next, {
71
+ ...record,
72
+ applied: 'error',
73
+ // CloudflareApiError messages are already redacted (`cloudflare.ts`);
74
+ // a non-Cloudflare error (e.g. a thrown plain Error) carries only its
75
+ // own message, never a raw fetch payload or token.
76
+ error: err instanceof Error ? err.message : String(err),
77
+ });
78
+ }
79
+ }
80
+ // The ingress is built from every `present` record, including ones whose
81
+ // DNS/apply state is `error` — otherwise one failed record would silently
82
+ // drop every other worktree's already-working route from the next upload.
83
+ const stillActive = next.filter((r) => r.desired === 'present');
84
+ // Remote config check first (I1): refuse to replace rules this tunnel does
85
+ // not own, rather than silently overwriting an out-of-band dashboard edit.
86
+ try {
87
+ const remote = await getIngressConfig(cfg, deps);
88
+ // Every hostname this tunnel identity has ever recorded is "managed",
89
+ // present or absent (N1): an `absent` record's hostname can still be in
90
+ // the remote ingress for one more reconcile (the PUT below removes it),
91
+ // and treating only `present` names as known wedges every later up/down
92
+ // the moment a single `down` runs, because the just-removed hostname then
93
+ // reads as a foreign unmanaged rule.
94
+ const expectedHostnames = new Set(next.map((r) => r.hostname).filter((h) => h !== undefined));
95
+ const unmanaged = remote.ingress.filter((rule) => typeof rule.hostname === 'string' &&
96
+ !expectedHostnames.has(rule.hostname));
97
+ if (unmanaged.length > 0) {
98
+ throw new Error(`refusing to replace unmanaged ingress rule(s): ${unmanaged.map((r) => r.hostname).join(', ')}`);
99
+ }
100
+ }
101
+ catch (err) {
102
+ ok = false;
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ for (const r of stillActive) {
105
+ next = upsertRecord(next, { ...r, applied: 'pending', error: message });
106
+ }
107
+ return { records: next, ok };
108
+ }
109
+ // BB-681 Task 2 — translate the neutral record (`hostHeader`/
110
+ // `insecureTls`) into Cloudflare's own `originRequest` shape HERE, the only
111
+ // place that knows Cloudflare's field names. Omission of `insecureTls`
112
+ // NEVER disables TLS verification (absent/false means verified) — only an
113
+ // explicit `true` sets `noTLSVerify: true`.
114
+ const ingress = [
115
+ ...stillActive
116
+ .slice()
117
+ .sort((a, b) => (a.hostname ?? '').localeCompare(b.hostname ?? ''))
118
+ .map((r) => ({
119
+ ...(r.hostname !== undefined ? { hostname: r.hostname } : {}),
120
+ service: r.service,
121
+ ...(r.hostHeader || r.insecureTls
122
+ ? {
123
+ originRequest: {
124
+ ...(r.hostHeader ? { httpHostHeader: r.hostHeader } : {}),
125
+ ...(r.insecureTls ? { noTLSVerify: true } : {}),
126
+ },
127
+ }
128
+ : {}),
129
+ })),
130
+ { service: 'http_status:404' },
131
+ ];
132
+ try {
133
+ await putIngressConfig(cfg, ingress, deps);
134
+ for (const r of stillActive) {
135
+ if (dnsErrorIds.has(`${r.worktree}:${r.key}`))
136
+ continue;
137
+ // Applied only when BOTH the ingress route and the owned DNS record are
138
+ // confirmed — the DNS phase above already confirmed ownership for
139
+ // every id that isn't in `dnsErrorIds`.
140
+ next = upsertRecord(next, {
141
+ ...r,
142
+ applied: 'applied',
143
+ error: undefined,
144
+ url: r.hostname ? `https://${r.hostname}` : r.url,
145
+ });
146
+ }
147
+ }
148
+ catch (err) {
149
+ ok = false;
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ for (const r of stillActive) {
152
+ // 'pending', not 'error': an upload failure is retryable and must not
153
+ // permanently drop this record from the next reconcile's ingress.
154
+ next = upsertRecord(next, { ...r, applied: 'pending', error: message });
155
+ }
156
+ // The ingress upload failed: nothing routes yet, so deleting an absent
157
+ // record's DNS now would be deleting the name for a route that is still
158
+ // live. Stop before the DNS-deletion phase.
159
+ return { records: next, ok };
160
+ }
161
+ // A desired-absent record with no DNS record ever created has nothing to
162
+ // delete remotely (M3) — drop its tombstone immediately rather than only
163
+ // ones that went through the create/adopt path.
164
+ const absent = next.filter((r) => r.desired === 'absent' && dnsRecordId(r));
165
+ const noOpAbsentIds = new Set(next
166
+ .filter((r) => r.desired === 'absent' && !dnsRecordId(r))
167
+ .map((r) => `${r.worktree}:${r.key}`));
168
+ next = next.filter((r) => !(r.desired === 'absent' && noOpAbsentIds.has(`${r.worktree}:${r.key}`)));
169
+ const kept = [];
170
+ for (const record of absent) {
171
+ const id = dnsRecordId(record);
172
+ try {
173
+ const existing = await findDnsRecord(cfg, record.hostname, deps);
174
+ if (existing &&
175
+ existing.id === id &&
176
+ existing.content === tunnelCnameTarget(cfg)) {
177
+ await deleteDnsRecord(cfg, id, deps);
178
+ }
179
+ // Tombstone cleared — drop it from the returned set entirely.
180
+ await context.checkpoint(next.filter((r) => `${r.worktree}:${r.key}` !== `${record.worktree}:${record.key}`));
181
+ }
182
+ catch (err) {
183
+ // Failed-cleanup tombstone: teardown partially failed, so this record
184
+ // stays (with its DNS id preserved) recording what's left to clean up,
185
+ // rather than the state losing track of it. Sibling records are
186
+ // unaffected — this loop never touches any key but its own.
187
+ ok = false;
188
+ kept.push({
189
+ ...record,
190
+ applied: 'error',
191
+ error: err instanceof Error ? err.message : String(err),
192
+ });
193
+ }
194
+ }
195
+ const absentIds = new Set(absent.map((r) => `${r.worktree}:${r.key}`));
196
+ next = next
197
+ .filter((r) => !(r.desired === 'absent' && absentIds.has(`${r.worktree}:${r.key}`)))
198
+ .concat(kept);
199
+ return { records: next, ok };
200
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-ports-cloudflare",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
4
+ "description": "bakers.build ports transport: a named Cloudflare Tunnel, reconciled via the Cloudflare API (DNS + ingress). Implements @bakers-build/addon-ports's PortsTransport contract.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js"
9
+ },
10
+ "files": [
11
+ "dist/src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
19
+ "directory": "bakers-cli/addons/ports-cloudflare"
20
+ },
21
+ "peerDependencies": {
22
+ "@bakers-build/addon-ports": "^0.1.0-next.38",
23
+ "@bakers-build/core": "^0.1.0-next.38"
24
+ }
25
+ }