@homeflare/alchemy 0.48.1 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +11 -0
  2. package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
  3. package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
  4. package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
  5. package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
  6. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
  7. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
  8. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
  9. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
  10. package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
  11. package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
  12. package/dist/cloudflare/fake-access.d.ts +40 -0
  13. package/dist/cloudflare/fake-access.d.ts.map +1 -0
  14. package/dist/cloudflare/fake-mesh.d.ts +7 -2
  15. package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
  16. package/dist/cloudflare/fake-tunnel.d.ts +48 -0
  17. package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
  18. package/dist/cloudflare/index.d.ts +15 -4
  19. package/dist/cloudflare/index.d.ts.map +1 -1
  20. package/dist/cloudflare/index.js +518 -7
  21. package/dist/cloudflare/index.js.map +15 -4
  22. package/dist/cloudflare/providers.d.ts +1 -1
  23. package/dist/cloudflare/providers.d.ts.map +1 -1
  24. package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
  25. package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
  26. package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
  27. package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
  28. package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
  29. package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
  30. package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
  31. package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
  32. package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
  33. package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
  34. package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
  35. package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
  36. package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
  37. package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
  38. package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
  39. package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
  40. package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
  41. package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
  42. package/dist/cloudflare/saas-oidc.d.ts +36 -0
  43. package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
  44. package/docs/cloudflared-tunnel.md +136 -0
  45. package/docs/credentials.md +1 -1
  46. package/docs/ownership.md +3 -2
  47. package/docs/saas-oidc.md +185 -0
  48. package/docs/upstream-conformance-families.md +1 -1
  49. package/docs/upstream-conformance.md +3 -0
  50. package/package.json +1 -1
  51. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  52. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  53. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  54. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  55. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  56. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  57. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  58. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  59. package/src/cloudflare/fake-access.ts +196 -0
  60. package/src/cloudflare/fake-mesh.ts +6 -3
  61. package/src/cloudflare/fake-tunnel.ts +148 -0
  62. package/src/cloudflare/index.ts +27 -4
  63. package/src/cloudflare/providers.ts +16 -3
  64. package/src/cloudflare/saas-oidc-api.ts +224 -0
  65. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  66. package/src/cloudflare/saas-oidc-error.ts +52 -0
  67. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  68. package/src/cloudflare/saas-oidc-form.ts +190 -0
  69. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  70. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  71. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  72. package/src/cloudflare/saas-oidc-props.ts +147 -0
  73. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  74. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  75. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  76. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  77. package/src/cloudflare/saas-oidc-team.ts +70 -0
  78. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  79. package/src/cloudflare/saas-oidc.test.ts +201 -0
  80. package/src/cloudflare/saas-oidc.ts +107 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `HomeFlare.Access.SaasOidcApplication` — the pure helpers the lifecycle uses: validation, the
3
+ * issuer URLs, drift, the write body and the attribute mapping. The props and attributes types are
4
+ * declared in saas-oidc-props.ts and re-exported here.
5
+ *
6
+ * ⛔ THE ATTRIBUTES NEVER HOLD THE CLIENT SECRET, and nothing here can: `ObservedApp`
7
+ * (saas-oidc-api.ts) has no field for it. A client that authenticates with PKCE alone declares
8
+ * `allowPkceWithoutClientSecret: true`, and then no secret exists to keep.
9
+ * ★ THE ISSUER IS `https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`. The team domain is a
10
+ * prop; the openbao copy this came from hardcoded one account's. Cloudflare also reports the
11
+ * same host in the app's own `domain` field (Alchemy's `Access.Application` doc: for `saas`
12
+ * "Cloudflare uses the OIDC issuer"), and `checkTeam` refuses a declaration that disagrees with it.
13
+ */
14
+ import { SaasOidcError } from './saas-oidc-error.ts';
15
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-props.ts';
16
+ import type { AppWrite, ObservedApp } from './saas-oidc-wire.ts';
17
+ export type { OidcGrantType, OidcScope, SaasOidcApplicationAttributes, SaasOidcApplicationProps, } from './saas-oidc-props.ts';
18
+ /** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
19
+ export declare const validateSaasOidc: (props: SaasOidcApplicationProps) => SaasOidcError | undefined;
20
+ export declare const issuerFor: (teamDomain: string, clientId: string) => string;
21
+ /**
22
+ * ⛔ A DECLARED TEAM DOMAIN THAT DISAGREES WITH THE APP'S OWN `domain` is refused: the issuer in
23
+ * the attributes would point at a different team than the one that holds the app.
24
+ * ⛔ WHEN CLOUDFLARE REPORTS NO `domain` there is nothing to compare, so the team the row already
25
+ * recorded (`knownTeam`, from state) stands in: a declaration that moves it is refused, because
26
+ * `issuer` is a declared stable and must not move under the row. With no `knownTeam` either
27
+ * (a first create or a cold adoption) there is nothing to compare against and the declaration
28
+ * stands.
29
+ */
30
+ export declare const checkTeam: (teamDomain: string, app: ObservedApp, knownTeam?: string) => SaasOidcError | undefined;
31
+ /** The request body for a create or an update. */
32
+ export declare const writeBody: (news: SaasOidcApplicationProps, name: string) => AppWrite;
33
+ /**
34
+ * Does the live app differ from the declaration? Compared: name, auth type, redirect URIs, scopes,
35
+ * grant types (each as a set), both lifetimes, the PKCE flag and the policies IN ORDER — the
36
+ * openbao copy's set plus the policy order, kept so adopting a live app is not newly a write.
37
+ * ⚠️ NOT COMPARED: `sessionDuration`, `allowedIdps`, `autoRedirectToIdentity`, `appLauncherVisible`,
38
+ * `appLauncherUrl`. They are written on create and on any sync, but a change to only those does
39
+ * not trigger one. Carried over unchanged; see saas-oidc.md.
40
+ */
41
+ export declare const needsSync: (news: SaasOidcApplicationProps, observed: ObservedApp, name: string) => boolean;
42
+ /**
43
+ * The attributes of a live app, or `undefined` when it lacks an id, an `aud` or a client id (so it
44
+ * is not a SaaS OIDC app this package can describe). `teamDomain` is the declared one, or, for a
45
+ * read that has no declaration (state written before the prop existed), the host Cloudflare reports.
46
+ */
47
+ export declare const toAttributes: (app: ObservedApp, accountId: string, fallbackName: string, declaredTeam: string | undefined) => SaasOidcApplicationAttributes | undefined;
48
+ //# sourceMappingURL=saas-oidc-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc-form.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,KAAK,EAAE,6BAA6B,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AACpG,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIjE,YAAY,EACV,aAAa,EACb,SAAS,EACT,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,sBAAsB,CAAC;AAI9B,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,UAAW,wBAAwB,KAAG,aAAa,GAAG,SAmBlF,CAAC;AAEF,eAAO,MAAM,SAAS,eAAgB,MAAM,YAAY,MAAM,KAAG,MACJ,CAAC;AAI9D;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,eACR,MAAM,OACb,WAAW,cACJ,MAAM,KACjB,aAAa,GAAG,SAalB,CAAC;AAKF,kDAAkD;AAClD,eAAO,MAAM,SAAS,SAAU,wBAAwB,QAAQ,MAAM,KAAG,QAmBvE,CAAC;AAgBH;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,SACd,wBAAwB,YACpB,WAAW,QACf,MAAM,KACX,OAcF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAClB,WAAW,aACL,MAAM,gBACH,MAAM,gBACN,MAAM,GAAG,SAAS,KAC/B,6BAA6B,GAAG,SA6BlC,CAAC"}
@@ -0,0 +1,50 @@
1
+ import * as State from 'alchemy/State';
2
+ import * as Effect from 'effect/Effect';
3
+ import * as Exit from 'effect/Exit';
4
+ import * as Layer from 'effect/Layer';
5
+ import { type FakeAccess } from './fake-access.ts';
6
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
7
+ import { SaasOidcApplication } from './saas-oidc.ts';
8
+ export declare const strict: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
9
+ export declare const ids: {
10
+ fqn: string;
11
+ id: string;
12
+ instanceId: string;
13
+ };
14
+ export declare const extra: {
15
+ bindings: never;
16
+ session: never;
17
+ };
18
+ export type P = Effect.Success<typeof SaasOidcApplication.Provider>;
19
+ /** A public PKCE client, the shape Headlamp declares (Q6). */
20
+ export declare const publicClient: (over?: Partial<SaasOidcApplicationProps>) => SaasOidcApplicationProps;
21
+ /** The wire `saas_app` of a live app that matches `publicClient()` exactly (no drift to sync). */
22
+ export declare const LIVE_OIDC: {
23
+ auth_type: string;
24
+ redirect_uris: string[];
25
+ scopes: string[];
26
+ grant_types: string[];
27
+ access_token_lifetime: string;
28
+ allow_pkce_without_client_secret: boolean;
29
+ };
30
+ /** Build the provider over the fake and hand it to `use`. */
31
+ export declare const run: <A, E>(fake: FakeAccess, use: (p: P) => Effect.Effect<A, E, never>) => Promise<A>;
32
+ export declare const reconcile: (p: P, news: SaasOidcApplicationProps, output?: SaasOidcApplicationAttributes) => Effect.Effect<SaasOidcApplicationAttributes, any, never>;
33
+ export declare const read: (p: P, olds: SaasOidcApplicationProps, output?: SaasOidcApplicationAttributes) => Effect.Effect<SaasOidcApplicationAttributes | undefined, any, never>;
34
+ /** The writes the fake saw, in order: `['POST', 'PUT', …]`. */
35
+ export declare const writes: (fake: FakeAccess) => string[];
36
+ type Rows = Record<string, Record<string, Record<string, State.ResourceState>>>;
37
+ /**
38
+ * ★ THE REAL ENGINE, NOT A MODEL OF IT: `Plan.make` + `apply` over an in-memory state store, as
39
+ * mesh-node-harness.ts does. `rows` is that store, so a test can serialise exactly what Alchemy
40
+ * would persist.
41
+ */
42
+ export declare const engine: (fake: FakeAccess, extra?: Layer.Layer<never, never, never>) => {
43
+ deploy: <A, E, R>(declare: Effect.Effect<A, E, R>) => Promise<Exit.Exit<unknown, unknown>>;
44
+ rows: Rows;
45
+ status: (fqn: string) => "created" | "creating" | "deleting" | "replaced" | "replacing" | "updated" | "updating" | undefined;
46
+ };
47
+ /** The failure sentence of a deploy, or `''` when it succeeded. */
48
+ export declare const failureOf: (exit: Exit.Exit<unknown, unknown>) => string;
49
+ export {};
50
+ //# sourceMappingURL=saas-oidc-harness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc-harness.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc-harness.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,KAAK,MAAM,eAAe,CAAC;AAGvC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAEtC,OAAO,EAAE,KAAK,UAAU,EAAQ,MAAM,kBAAkB,CAAC;AAEzD,OAAO,KAAK,EAAE,6BAA6B,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,mBAAmB,EAA+B,MAAM,gBAAgB,CAAC;AAclF,eAAO,MAAM,MAAM,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,UAAU,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KAAG,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CACpB,CAAC;AAEnE,eAAO,MAAM,GAAG;IAAK,GAAG;IAAoB,EAAE;IAAc,UAAU;CAAS,CAAC;AAChF,eAAO,MAAM,KAAK;IAAK,QAAQ,EAAQ,KAAK;IAAE,OAAO,EAAe,KAAK;CAAE,CAAC;AAE5E,MAAM,MAAM,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,mBAAmB,CAAC,QAAQ,CAAC,CAAC;AAEpE,8DAA8D;AAC9D,eAAO,MAAM,YAAY,UACjB,OAAO,CAAC,wBAAwB,CAAC,KACtC,wBAaD,CAAC;AAEH,kGAAkG;AAClG,eAAO,MAAM,SAAS;IACpB,SAAS;IACT,aAAa;IACb,MAAM;IACN,WAAW;IACX,qBAAqB;IACrB,gCAAgC;CACjC,CAAC;AAEF,6DAA6D;AAC7D,eAAO,MAAM,GAAG,GAAI,CAAC,EAAE,CAAC,QAAQ,UAAU,OAAO,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,eAWlF,CAAC;AAEJ,eAAO,MAAM,SAAS,MACjB,CAAC,QACE,wBAAwB,WACrB,6BAA6B,6DAC6B,CAAC;AAEtE,eAAO,MAAM,IAAI,MACZ,CAAC,QACE,wBAAwB,WACrB,6BAA6B,yEAKvC,CAAC;AAEF,+DAA+D;AAC/D,eAAO,MAAM,MAAM,SAAU,UAAU,aAC2B,CAAC;AAEnE,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;AAEhF;;;;GAIG;AACH,eAAO,MAAM,MAAM,SAAU,UAAU,UAAS,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC;aAO9D,CAAC,EAAE,CAAC,EAAE,CAAC,WAAW,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;;kBAexC,MAAM;CAEvB,CAAC;AAEF,mEAAmE;AACnE,eAAO,MAAM,SAAS,SAAU,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,WACG,CAAC"}
@@ -0,0 +1,14 @@
1
+ import * as Effect from 'effect/Effect';
2
+ import { SaasOidcError } from './saas-oidc-error.ts';
3
+ import { type SaasOidcApplicationAttributes, type SaasOidcApplicationProps } from './saas-oidc-form.ts';
4
+ /**
5
+ * Owned: refresh by the stored id ONLY (gone is `undefined`, never a same-named app). Cold: find the app by `applicationId` or exact name and hand it
6
+ * back `Unowned`, so Alchemy refuses to take it over until the stack says `adopt(true)`.
7
+ * ★ `olds` MAY PREDATE `teamDomain` (state written by the openbao copy of this resource): the host
8
+ * Cloudflare reports for the app stands in, so the first plan after the move can still read.
9
+ */
10
+ export declare const readSaasOidc: (accountId: string, id: string, olds: SaasOidcApplicationProps | undefined, output: SaasOidcApplicationAttributes | undefined) => Effect.Effect<SaasOidcApplicationAttributes | undefined, import("@distilled.cloud/cloudflare/zero-trust").AccessReferenceNotFound | import("@distilled.cloud/cloudflare/Errors").CloudflareError | import("@distilled.cloud/cloudflare/Errors").CloudflareHttpError | import("@distilled.cloud/cloudflare/Errors").CloudflareRateLimited | import("effect/http/HttpClientError").HttpClientError | import("@distilled.cloud/cloudflare/Errors").InvalidRoute | SaasOidcError | import("@distilled.cloud/cloudflare/Errors").UnknownCloudflareError | import("@distilled.cloud/cloudflare/Credentials").CredentialsError | import("@distilled.cloud/grafana").DefaultErrors, import("alchemy").InstanceId | import("alchemy").Stack | import("alchemy").Stage | import("@distilled.cloud/cloudflare/Protocol").CloudflareOpContext>;
11
+ /** observe, ensure (create or sync), read back. A no-op when nothing drifted. */
12
+ export declare const reconcileSaasOidc: (accountId: string, id: string, news: SaasOidcApplicationProps, output: SaasOidcApplicationAttributes | undefined) => Effect.Effect<SaasOidcApplicationAttributes, import("@distilled.cloud/cloudflare/zero-trust").AccessDestinationConflict | import("@distilled.cloud/cloudflare/zero-trust").AccessReferenceNotFound | import("@distilled.cloud/cloudflare/Errors").CloudflareError | import("@distilled.cloud/cloudflare/Errors").CloudflareHttpError | import("@distilled.cloud/cloudflare/Errors").CloudflareRateLimited | import("@distilled.cloud/caddy").ConfigError | import("effect/http/HttpClientError").HttpClientError | import("@distilled.cloud/cloudflare/Errors").InvalidRoute | import("@distilled.cloud/cloudflare/Credentials").OAuthRefreshError | import("@distilled.cloud/cloudflare/zero-trust").OrganizationNotFound | SaasOidcError | import("@distilled.cloud/cloudflare/Errors").UnknownCloudflareError | import("@distilled.cloud/grafana").DefaultErrors, import("alchemy").InstanceId | import("alchemy").Stack | import("alchemy").Stage | import("@distilled.cloud/cloudflare/Protocol").CloudflareOpContext>;
13
+ export declare const deleteSaasOidc: (output: SaasOidcApplicationAttributes) => Effect.Effect<void, import("@distilled.cloud/cloudflare/Errors").CloudflareError | import("@distilled.cloud/cloudflare/Errors").CloudflareHttpError | import("@distilled.cloud/cloudflare/Errors").CloudflareRateLimited | import("@distilled.cloud/caddy").ConfigError | import("effect/http/HttpClientError").HttpClientError | import("@distilled.cloud/cloudflare/Errors").InvalidRoute | import("@distilled.cloud/cloudflare/Credentials").OAuthRefreshError | SaasOidcError | import("@distilled.cloud/cloudflare/Errors").UnknownCloudflareError | import("@distilled.cloud/grafana").DefaultErrors, import("@distilled.cloud/cloudflare/Protocol").CloudflareOpContext>;
14
+ //# sourceMappingURL=saas-oidc-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc-lifecycle.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc-lifecycle.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EACL,KAAK,6BAA6B,EAClC,KAAK,wBAAwB,EAM9B,MAAM,qBAAqB,CAAC;AAqC7B;;;;;GAKG;AACH,eAAO,MAAM,YAAY,cACZ,MAAM,MACb,MAAM,QACJ,wBAAwB,GAAG,SAAS,UAClC,6BAA6B,GAAG,SAAS,uyBAiB/C,CAAC;AASL,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,cACjB,MAAM,MACb,MAAM,QACJ,wBAAwB,UACtB,6BAA6B,GAAG,SAAS,g+BAmD/C,CAAC;AAEL,eAAO,MAAM,cAAc,WAAY,6BAA6B,opBACjB,CAAC"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The declared shape of `HomeFlare.Access.SaasOidcApplication` and what it reports back, apart from
3
+ * the pure helpers that use them (saas-oidc-form.ts re-exports both types, so imports keep working).
4
+ *
5
+ * ★ EVERY PROP FIELD CARRIES A JSDOC (provider standard S5). Where the wording below is the API's,
6
+ * it is the SDK's own field doc on the request type
7
+ * (`@distilled.cloud/cloudflare@1.0.0-rc.13` `zero-trust` `CreateAccessApplicationForAccountRequest`
8
+ * and `AccessApplicationsCreateRequestSaasAppOIDCSaaSApp`, src/services/zero_trust.ts:9025-9053 and
9
+ * :9728-9794), so a reader gets the vendor's meaning without leaving the editor.
10
+ * ⛔ THE ATTRIBUTES NEVER HOLD THE CLIENT SECRET, and nothing here can: there is no field for it.
11
+ */
12
+ /**
13
+ * An OIDC flow Access can serve for the client: the five literals the SDK names
14
+ * (`AccessApplicationsCreateRequestSaasAppOIDCSaaSAppGrantTypesItem`, zero_trust.ts:8946-8951).
15
+ */
16
+ export type OidcGrantType = 'authorization_code' | 'authorization_code_with_pkce' | 'refresh_tokens' | 'hybrid' | 'implicit';
17
+ /** The user information the client may request from Access. */
18
+ export type OidcScope = 'openid' | 'email' | 'profile' | 'groups';
19
+ export interface SaasOidcApplicationProps {
20
+ /**
21
+ * Live Access application UUID. Set this when adopting a SaaS app that already exists. Without
22
+ * it the app is found by exact `name` among saas apps, which also works but refuses two matches.
23
+ */
24
+ readonly applicationId?: string;
25
+ /** The app's name. Defaults to a physical name derived from the logical id. */
26
+ readonly name?: string;
27
+ /**
28
+ * The Zero Trust team domain the issuer lives on, a bare hostname such as
29
+ * `example.cloudflareaccess.com`: no scheme, no path. Not read from the environment, so the
30
+ * issuer in the attributes is the one the declaration says.
31
+ */
32
+ readonly teamDomain: string;
33
+ /**
34
+ * How long Access sessions for this app last, in the API's duration format (`300ms` or `2h45m`).
35
+ * Written on create and on any sync; not drift-checked.
36
+ * @default '24h'
37
+ */
38
+ readonly sessionDuration?: string;
39
+ /**
40
+ * Ids of the identity providers users may pick for this app. Written on create and on any sync;
41
+ * not drift-checked.
42
+ * @default every IdP configured in the account (the field is left out of the write)
43
+ */
44
+ readonly allowedIdps?: ReadonlyArray<string>;
45
+ /**
46
+ * `true` makes users skip the identity-provider selection step; the API requires exactly one
47
+ * entry in `allowedIdps` then. Written on create and on any sync; not drift-checked.
48
+ * @default left out of the write (the API's default)
49
+ */
50
+ readonly autoRedirectToIdentity?: boolean;
51
+ /**
52
+ * Show the app in the App Launcher. Written on create and on any sync; not drift-checked.
53
+ * @default false
54
+ */
55
+ readonly appLauncherVisible?: boolean;
56
+ /**
57
+ * Ids of reusable Access policies, in ascending order of precedence (the SDK's own wording:
58
+ * zero_trust.ts:9774). The ORDER is part of the declaration: a reorder is drift and is synced.
59
+ */
60
+ readonly policies: ReadonlyArray<string>;
61
+ /** The OIDC client: the `saas_app` object of the Access application. */
62
+ readonly saasApp: {
63
+ /** The authentication protocol of the SaaS app. Only `oidc` is supported by this resource. */
64
+ readonly authType: 'oidc';
65
+ /**
66
+ * The permitted URLs for Cloudflare to return authorization codes and tokens to. Compared as
67
+ * a set (order is not significant to the API).
68
+ */
69
+ readonly redirectUris: ReadonlyArray<string>;
70
+ /** The user information shared with the client. Compared as a set. */
71
+ readonly scopes: ReadonlyArray<OidcScope>;
72
+ /** The OIDC flows the client may use. Compared as a set. */
73
+ readonly grantTypes: ReadonlyArray<OidcGrantType>;
74
+ /** Lifetime of the access token after creation: `m` or `h` units, `1m` to `24h` (the SDK field doc). */
75
+ readonly accessTokenLifetime: string;
76
+ /**
77
+ * How long a refresh token is valid after creation: `m`, `h` or `d` units, longer than `1m`
78
+ * (the SDK field doc). Needed for the `refresh_tokens` grant. Sent as `refreshTokenOptions.lifetime`.
79
+ * @default left out of the write
80
+ */
81
+ readonly refreshTokenLifetime?: string;
82
+ /**
83
+ * `true` makes this a PUBLIC client: the token endpoint does not require a client secret when
84
+ * the `authorization_code_with_pkce` grant is used, and then no secret exists to keep. Refused
85
+ * without that grant.
86
+ * @default false (a confidential client)
87
+ */
88
+ readonly allowPkceWithoutClientSecret?: boolean;
89
+ /**
90
+ * The URL the app's tile in the App Launcher sends users to. Written on create and on any
91
+ * sync; not drift-checked.
92
+ * @default left out of the write
93
+ */
94
+ readonly appLauncherUrl?: string;
95
+ };
96
+ }
97
+ export interface SaasOidcApplicationAttributes {
98
+ /** The Access application UUID. */
99
+ readonly applicationId: string;
100
+ /** The application audience tag, the `aud` of the Access JWTs for this app. */
101
+ readonly aud: string;
102
+ /** The `domain` Cloudflare reports for the app (the OIDC issuer without its scheme). */
103
+ readonly domain: string;
104
+ /** The app's name as Cloudflare reports it. */
105
+ readonly name: string;
106
+ /** The account the app lives in. */
107
+ readonly accountId: string;
108
+ /** The declared team domain (or, for state that predates the prop, the host Cloudflare reports). */
109
+ readonly teamDomain: string;
110
+ /** The OIDC client id, for the relying party's configuration. */
111
+ readonly clientId: string;
112
+ /** `https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`. */
113
+ readonly issuer: string;
114
+ /** `<issuer>/authorization`. */
115
+ readonly authorizationEndpoint: string;
116
+ /** `<issuer>/token`. */
117
+ readonly tokenEndpoint: string;
118
+ /** The signing keys, for a verifier that validates ID tokens. */
119
+ readonly jwksEndpoint: string;
120
+ /** `<issuer>/userinfo`. */
121
+ readonly userinfoEndpoint: string;
122
+ /** `<issuer>/.well-known/openid-configuration`. */
123
+ readonly configurationEndpoint: string;
124
+ /** The redirect URIs the live app permits. */
125
+ readonly redirectUris: ReadonlyArray<string>;
126
+ /** The scopes the live app serves. */
127
+ readonly scopes: ReadonlyArray<string>;
128
+ /** The grant types the live app allows. */
129
+ readonly grantTypes: ReadonlyArray<string>;
130
+ /** The live access token lifetime, or `''` when Cloudflare reports none. */
131
+ readonly accessTokenLifetime: string;
132
+ /** The live refresh token lifetime, or `''` when Cloudflare reports none. */
133
+ readonly refreshTokenLifetime: string;
134
+ /** When Cloudflare created the app, if it says. */
135
+ readonly createdAt: string | undefined;
136
+ /** When Cloudflare last updated the app, if it says. */
137
+ readonly updatedAt: string | undefined;
138
+ }
139
+ //# sourceMappingURL=saas-oidc-props.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc-props.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc-props.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
2
+ import * as Effect from 'effect/Effect';
3
+ import { SaasOidcError } from './saas-oidc-error.ts';
4
+ /**
5
+ * A refusal when the account's organization is read and its domain is not the declared one; else
6
+ * `undefined` (it matches, or it could not be read, see above). Hostnames compare case-insensitively.
7
+ */
8
+ export declare const checkTeamBeforeCreate: (accountId: string, teamDomain: string) => Effect.Effect<SaasOidcError | undefined, import("@distilled.cloud/cloudflare/Errors").CloudflareError | import("@distilled.cloud/cloudflare/Errors").CloudflareHttpError | import("@distilled.cloud/cloudflare/Errors").CloudflareRateLimited | import("@distilled.cloud/caddy").ConfigError | import("effect/http/HttpClientError").HttpClientError | import("@distilled.cloud/cloudflare/Errors").InvalidRoute | import("@distilled.cloud/cloudflare/Credentials").OAuthRefreshError | zeroTrust.OrganizationNotFound | SaasOidcError | import("@distilled.cloud/cloudflare/Errors").UnknownCloudflareError | import("@distilled.cloud/grafana").DefaultErrors, zeroTrust.CloudflareOpContext>;
9
+ //# sourceMappingURL=saas-oidc-team.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc-team.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc-team.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,SAAS,MAAM,wCAAwC,CAAC;AACpE,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,aAAa,EAAY,MAAM,sBAAsB,CAAC;AAyB/D;;;GAGG;AACH,eAAO,MAAM,qBAAqB,cAAe,MAAM,cAAc,MAAM,qqBASxE,CAAC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The wire shapes of a SaaS OIDC application, and the parse that reads them: pure, no Effect, no
3
+ * request. saas-oidc-api.ts says why the read is raw JSON (the SDK's responses drop `saas_app`).
4
+ *
5
+ * ⛔ `parseApp` COPIES NAMED FIELDS ONLY, and `client_secret` is not among them: an `ObservedApp`
6
+ * has nowhere to hold the client secret, so no code path can persist or log one.
7
+ */
8
+ /** The OIDC half of a live SaaS app, from the wire JSON. */
9
+ export interface ObservedSaas {
10
+ readonly authType: string | undefined;
11
+ readonly clientId: string | undefined;
12
+ readonly redirectUris: ReadonlyArray<string> | undefined;
13
+ readonly scopes: ReadonlyArray<string> | undefined;
14
+ readonly grantTypes: ReadonlyArray<string> | undefined;
15
+ readonly accessTokenLifetime: string | undefined;
16
+ readonly refreshTokenLifetime: string | undefined;
17
+ readonly allowPkceWithoutClientSecret: boolean | undefined;
18
+ }
19
+ /** A live Access application as this package sees it. ⛔ There is no secret field, on purpose. */
20
+ export interface ObservedApp {
21
+ readonly id: string | undefined;
22
+ readonly aud: string | undefined;
23
+ readonly type: string | undefined;
24
+ readonly name: string | undefined;
25
+ readonly domain: string | undefined;
26
+ readonly createdAt: string | undefined;
27
+ readonly updatedAt: string | undefined;
28
+ readonly policyIds: ReadonlyArray<string>;
29
+ readonly saasApp: ObservedSaas | undefined;
30
+ }
31
+ /** What a create or an update sends. Built by `writeBody` in saas-oidc-form.ts. */
32
+ export interface AppWrite {
33
+ readonly name: string;
34
+ readonly sessionDuration: string;
35
+ readonly allowedIdps: ReadonlyArray<string> | undefined;
36
+ readonly autoRedirectToIdentity: boolean | undefined;
37
+ readonly appLauncherVisible: boolean;
38
+ readonly policies: ReadonlyArray<string>;
39
+ readonly saasApp: {
40
+ readonly authType: 'oidc';
41
+ readonly redirectUris: ReadonlyArray<string>;
42
+ readonly scopes: ReadonlyArray<string>;
43
+ readonly grantTypes: ReadonlyArray<string>;
44
+ readonly accessTokenLifetime: string;
45
+ readonly refreshTokenOptions?: {
46
+ readonly lifetime: string;
47
+ };
48
+ readonly allowPkceWithoutClientSecret: boolean;
49
+ readonly appLauncherUrl: string | undefined;
50
+ };
51
+ }
52
+ export declare const record: (value: unknown) => Record<string, unknown> | undefined;
53
+ /**
54
+ * Named fields only, from Cloudflare's snake_case JSON. ⛔ `client_secret` is not among them, and
55
+ * `public_key`, `custom_claims` and `hybrid_and_implicit_options` are not used by this resource.
56
+ */
57
+ export declare const parseApp: (raw: Record<string, unknown>) => ObservedApp;
58
+ //# sourceMappingURL=saas-oidc-wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc-wire.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc-wire.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,4DAA4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;IACzD,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;IACnD,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;IACvD,QAAQ,CAAC,mBAAmB,EAAE,MAAM,GAAG,SAAS,CAAC;IACjD,QAAQ,CAAC,oBAAoB,EAAE,MAAM,GAAG,SAAS,CAAC;IAClD,QAAQ,CAAC,4BAA4B,EAAE,OAAO,GAAG,SAAS,CAAC;CAC5D;AAED,iGAAiG;AACjG,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC1C,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,SAAS,CAAC;CAC5C;AAED,mFAAmF;AACnF,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;IACxD,QAAQ,CAAC,sBAAsB,EAAE,OAAO,GAAG,SAAS,CAAC;IACrD,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACzC,QAAQ,CAAC,OAAO,EAAE;QAChB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QAC7C,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QACvC,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QAC3C,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;QACrC,QAAQ,CAAC,mBAAmB,CAAC,EAAE;YAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAC;QAC7D,QAAQ,CAAC,4BAA4B,EAAE,OAAO,CAAC;QAC/C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;KAC7C,CAAC;CACH;AAOD,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAGnD,CAAC;AAuBhB;;;GAGG;AACH,eAAO,MAAM,QAAQ,QAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,WA2BvD,CAAC"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `HomeFlare.Access.SaasOidcApplication` — a Cloudflare Access SaaS application that makes Access an
3
+ * OIDC identity provider for one relying party (OpenBao's JWT/OIDC auth, Headlamp, a Kubernetes
4
+ * API server). Usage and the secret rules: docs/saas-oidc.md.
5
+ *
6
+ * 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Access.Application`. Alchemy beta.81's Application has
7
+ * no `saas_app` field (its source mentions `saas` only as a type and in two comments), and
8
+ * `@distilled.cloud/cloudflare` rc.13 decodes a saas app's GET and create response WITHOUT it, so
9
+ * the client id cannot be read back through either. See saas-oidc-api.ts.
10
+ *
11
+ * ⛔ THE TYPE ID IS A STATE KEY, AND THIS ONE WAS MOVED, NOT RENAMED. It is the string the
12
+ * homeflare-openbao stack's live `OpenBaoOidcSaas` row was written under. A different string would
13
+ * make the next plan see a new resource and an orphaned one: a second OIDC app, with a new
14
+ * client id and `aud`, and a delete of the live one unless it were retained. `tests` pin it.
15
+ *
16
+ * ★ `retain` BY DEFAULT, like every kit resource whose deletion breaks its consumers: deleting the
17
+ * app deletes the client id every relying party is configured with. The openbao declaration
18
+ * already pipes `RemovalPolicy.retain()`; `delete` stays fully implemented for a declaration
19
+ * that opts in with `.pipe(RemovalPolicy.destroy())`.
20
+ */
21
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
22
+ import * as Cloudflare from 'alchemy/Cloudflare';
23
+ import * as Provider from 'alchemy/Provider';
24
+ import { Resource, type ResourceClass } from 'alchemy/Resource';
25
+ import * as HttpClient from 'effect/http/HttpClient';
26
+ import type { Providers } from './providers.ts';
27
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
28
+ export interface SaasOidcApplication extends Resource<'HomeFlare.Access.SaasOidcApplication', SaasOidcApplicationProps, SaasOidcApplicationAttributes, never, Providers> {
29
+ }
30
+ export declare const SaasOidcApplication: ResourceClass<SaasOidcApplication>;
31
+ /**
32
+ * ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, as MeshNodeProvider does
33
+ * (mesh-node.ts): correct whether or not the stack also merges `Cloudflare.providers()`.
34
+ */
35
+ export declare const SaasOidcApplicationProvider: () => import("effect/Layer").Layer<Provider.Provider<SaasOidcApplication>, never, Cloudflare.CloudflareEnvironment | Credentials | HttpClient.HttpClient | import("alchemy").Stack | import("alchemy").Stage>;
36
+ //# sourceMappingURL=saas-oidc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saas-oidc.d.ts","sourceRoot":"","sources":["../../src/cloudflare/saas-oidc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,yCAAyC,CAAC;AACtE,OAAO,KAAK,UAAU,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGhE,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAC;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,6BAA6B,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAEnG,MAAM,WAAW,mBAAoB,SAAQ,QAAQ,CACnD,sCAAsC,EACtC,wBAAwB,EACxB,6BAA6B,EAC7B,KAAK,EACL,SAAS,CACV;CAAG;AAEJ,eAAO,MAAM,mBAAmB,EAAE,aAAa,CAAC,mBAAmB,CAG/D,CAAC;AAOL;;;GAGG;AACH,eAAO,MAAM,2BAA2B,+MAqDrC,CAAC"}
@@ -0,0 +1,136 @@
1
+ # CloudflaredTunnel — a Cloudflare Tunnel without its token in state
2
+
3
+ `CloudflaredTunnel` (`Cloudflare.CloudflaredTunnel`) declares a Cloudflare Tunnel of the cloudflared
4
+ kind (`cfd_tunnel`): the object whose `<id>.cfargotunnel.com` target DNS records point at and whose
5
+ remote configuration holds the ingress rules. It declares **the tunnel object only**: no ingress
6
+ rules, no routes, no DNS record, no connector Deployment. Those are separate declarations that name
7
+ this tunnel's `id`. Read 2026-10-09 against `alchemy@2.0.0-beta.81` and
8
+ `@distilled.cloud/cloudflare@1.0.0-rc.13`.
9
+
10
+ ## Why not `Cloudflare.Tunnel.Tunnel`
11
+
12
+ - ⛔ **It puts the connector token in state.** Its `reconcile`, `read` and `list` call
13
+ `getTunnelCloudflaredToken` and keep the result as `token: Redacted<string>`
14
+ (`alchemy/src/Cloudflare/Tunnel/Tunnel.ts`). Alchemy's state encoder writes a `Redacted` value as
15
+ `{"__redacted__": "<the value>"}` (`alchemy/src/State/StateEncoding.ts`, `encodeState`, which
16
+ LocalState, HttpStateStore and PostgresState all call before writing). The state store is not
17
+ encrypted, and the token is what `cloudflared tunnel run --token` accepts, so anyone who can read
18
+ state can run a connector for the tunnel and receive its traffic.
19
+ - **Its plans need a Write token.** The token endpoint is expected to need a Write permission, so even a plan
20
+ would. **UNVERIFIED for `cfd_tunnel`**: the only citation is the sibling Mesh node token endpoint
21
+ ("Cloudflare One Connectors Write", per its API reference, read 2026-09-21; see
22
+ `src/cloudflare/mesh-node-form.ts`), and the `cfd_tunnel` token endpoint's permission was not
23
+ read. `CloudflaredTunnel` never calls it, so a plan does not depend on the answer.
24
+
25
+ `CloudflaredTunnel` follows the same pattern as [MeshNode](./mesh-node.md): attributes with no
26
+ secret, adoption by exact name, `retain` by default. The type string is `CloudflaredTunnel`, not
27
+ `Tunnel`, so it cannot be confused with upstream's `Cloudflare.Tunnel.Tunnel` in a stack that uses both.
28
+
29
+ ## Declaring a tunnel
30
+
31
+ ```ts
32
+ import { CloudflaredTunnel, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
33
+
34
+ // providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
35
+ const admin = yield * CloudflaredTunnel('k8s-admin', { name: 'k8s-admin' });
36
+ // admin.id → the tunnel UUID: `${admin.id}.cfargotunnel.com` is the DNS target
37
+ ```
38
+
39
+ | prop | type | notes |
40
+ | ------ | -------- | --------------------------------------------------------------------- |
41
+ | `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
42
+
43
+ Attributes: `id`, `accountId` and `name`. ★ There is no `status`: it moves as connectors attach and
44
+ drop, so a stored copy would make every `alchemy drift` flag the tunnel; read health from the API
45
+ or the metrics. ⛔ There is no token, secret or `tunnelSecret` attribute, and none is sent. The request schema documents `tunnel_secret` as the password for a _locally-managed_ tunnel
46
+ (`@distilled.cloud/cloudflare@1.0.0-rc.13`, `src/services/zero_trust.ts`, `CreateTunnelCloudflaredRequest`);
47
+ this resource always sends `config_src: "cloudflare"`. **UNVERIFIED**: that Cloudflare itself generates and
48
+ keeps a secret when `tunnel_secret` is omitted for such a tunnel; the schema says only that the field is optional. `accountId` is not a secret; it is what
49
+ `read` and `delete` address, so they never act on whatever account the environment names today.
50
+
51
+ The account and credentials come from Alchemy's own Cloudflare environment
52
+ (`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
53
+ `CLOUDFLARE_ACCOUNT_ID` in CI.
54
+
55
+ ### Remotely configured only
56
+
57
+ The tunnel is always created with `config_src: "cloudflare"` (managed through the API and the Zero
58
+ Trust dashboard), sent explicitly. The SDK types the field as optional (`local` or `cloudflare`)
59
+ and does not document the default (`CreateTunnelCloudflaredRequest`, `zero_trust.ts`). It is
60
+ create-only: the PATCH body has `name` and `tunnelSecret` only. A tunnel found by name whose
61
+ `config_src` is `local` is **refused on adoption** with a sentence and left untouched, because its
62
+ ingress is a YAML file on its origin and remote rules would be accepted and never applied.
63
+ ⚠️ UNVERIFIED: that every real GET/list response carries `config_src`. A response without it is
64
+ accepted; only an explicit `local` is refused.
65
+
66
+ ## The token is not this resource's business
67
+
68
+ Whatever installs the connector fetches the token at that moment and hands it straight to the
69
+ workload's secret store (`GET /accounts/{id}/cfd_tunnel/{id}/token`, which needs a Write
70
+ permission). It must not go into Alchemy state, a log line or a CI variable. No function in this
71
+ package calls that endpoint, `observe` copies named fields only, and
72
+ `cloudflared-tunnel-state.test.ts` proves the persisted state holds no token. ⚠️ Never deploy with
73
+ `DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response body.
74
+
75
+ ## What each change does
76
+
77
+ | change | answer | what happens on Cloudflare |
78
+ | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
79
+ | `name` | `update` | `PATCH`. The id, connectors and `cfargotunnel.com` target survive. |
80
+ | account (provider env) | `replace`, create-first | A new tunnel in the new account. Under `retain` the old one stays. |
81
+ | nothing | none | Zero writes and no token read. An existing row's plan makes no request (`diff` is pure); the cold probe is one list. |
82
+
83
+ ⛔ **Removal policy: `retain` by default**, like MeshNode and the other kit resources whose deletion
84
+ breaks their consumers. Deleting a tunnel drops its remote configuration and orphans every DNS record
85
+ that targets it; a new tunnel has a new id and token. Dropping the declaration or `alchemy destroy`
86
+ leaves the tunnel live and only drops it from state. Opt a declaration into deletion with
87
+ `.pipe(RemovalPolicy.destroy())`. ⚠️ Under `retain` an account-change replace leaves the old tunnel
88
+ live and unmanaged.
89
+
90
+ ## Adoption
91
+
92
+ An existing tunnel is found by **exact** name among `cfd_tunnel`s and returned `Unowned`. Alchemy
93
+ refuses to take it over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A
94
+ deleted tunnel, a tunnel of another type (a `warp_connector` Mesh node shares the name space) and a
95
+ prefix match are never matched, and a name that matches two live tunnels is refused. Adoption is
96
+ idempotent: the adopted row holds the id, so a repeat deploy takes the owned path and writes
97
+ nothing. ⛔ The name probe runs only when there is **no** stored row. A stored id that the API no
98
+ longer resolves (deleted out of band) reads as missing, never as a same-named tunnel: `alchemy drift`
99
+ treats what `read` returns as already ours, so a probe there would write a foreign tunnel into this
100
+ row without `adopt(true)`. Drift reports it missing and recreates, and the create refuses and
101
+ names the holder. Without `adopt(true)`, a create whose name is held by another tunnel refuses and names the
102
+ holder; it never converges on it, and the sentence says not to delete it.
103
+
104
+ An interrupted create (the tunnel was made, its state was not saved) is found the same way on the
105
+ next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
106
+ 5xx, and the retry is answered code 1013 by the tunnel the first attempt made: the error then names
107
+ that tunnel's id and is not adopted silently, because a concurrent creator looks the same.
108
+
109
+ ## Deleting
110
+
111
+ `delete` runs only under `RemovalPolicy.destroy()`. It addresses the **stored id and account**,
112
+ never a name: it reads that id first, does nothing when the tunnel is already gone or soft-deleted,
113
+ and refuses an empty id before any request. The API refuses a delete while connectors are attached
114
+ ("The tunnel must have no active connections", the SDK's own doc on `deleteTunnelCloudflared`); that
115
+ error passes through unchanged, so scale cloudflared to zero first. No retry loop is added: a
116
+ connector does not detach within seconds on its own. A tunnel that vanishes between the pre-read and
117
+ the DELETE is a successful delete: the 404 reaches the caller as `NotFound` at runtime though the SDK's
118
+ typed error union omits it (`protocol.ts`, `HTTP_STATUS_MAP`), so it is matched by tag name and caught. ⚠️ UNVERIFIED against the real API: the fake
119
+ answers 404 / code 1002 there, the same shape `getTunnelCloudflared` documents.
120
+
121
+ `list` is empty and `nuke` skips this type: Alchemy's own `Tunnel.Tunnel` already enumerates every
122
+ `cfd_tunnel` for `alchemy unsafe nuke` (fetching each token to do it), and a second listing would
123
+ delete each tunnel twice.
124
+
125
+ ## Tests
126
+
127
+ Three test files in `src/cloudflare/` (plus `cloudflared-tunnel-harness.ts`, their shared helpers)
128
+ run the provider against a fake API (`fake-tunnel.ts`) that hands out a token on every surface it can:
129
+
130
+ - `cloudflared-tunnel.test.ts`: the lifecycle; the create body is `{name, config_src}` and no
131
+ request ever touches `/token`.
132
+ - `cloudflared-tunnel-edges.test.ts`: each refusal and recovery, written to fail when its guard
133
+ is deleted.
134
+ - `cloudflared-tunnel-state.test.ts`: Alchemy's real plan/apply, then `encodeState` over the stored
135
+ rows: no `__redacted__` marker, no `token` key, no token value, with a negative control showing
136
+ the same encoder does write a `Redacted` token. Also the removal policy and adoption.
@@ -3,7 +3,7 @@
3
3
  Moved out of the README (2026-09-21) to make room under its 200-line cap; the text is unchanged.
4
4
 
5
5
  `CLOUDFLARE_API_TOKEN` is read from the environment at call time, never at module scope.
6
- `MeshNode` instead resolves credentials and the account the way `Cloudflare.providers()` does
6
+ `MeshNode` and `CloudflaredTunnel` instead resolve credentials and the account the way `Cloudflare.providers()` does
7
7
  (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in CI).
8
8
 
9
9
  ⛔ **Mint a short-lived, scoped token** — do not reuse a long-lived one, and never a Global
package/docs/ownership.md CHANGED
@@ -100,8 +100,9 @@ escalating to `--adopt` only on `OwnedBySomeoneElse`
100
100
  ([inspecting state](https://alchemy.run/cli/inspecting-state#recover-from-bad-state)). For the
101
101
  families above that escalation always comes (decided 2026-09-21): an object whose row is gone is
102
102
  `Unowned`, so the deploy needs `--adopt`, or `adopt(true)` on its declaration. `MeshNode`
103
- ([mesh-node.md](./mesh-node.md)) and `R2BucketLock` (`src/cloudflare/r2-bucket-lock.ts`) need it
104
- too. A `CaddyConfig` running the declared config re-imports without it, and so does every family
103
+ ([mesh-node.md](./mesh-node.md)), `CloudflaredTunnel`
104
+ ([cloudflared-tunnel.md](./cloudflared-tunnel.md)), `SaasOidcApplication` ([saas-oidc.md](./saas-oidc.md)) and
105
+ `R2BucketLock` (`src/cloudflare/r2-bucket-lock.ts`) need it too. A `CaddyConfig` running the declared config re-imports without it, and so does every family
105
106
  whose `read` never answers `Unowned`: the `pveHandlers` resources (Limits), the `forgejoHandlers`
106
107
  resources and the Talos resources. Read the takeover first with `hf-adopt-verify`
107
108
  ([adopt-verify.md](./adopt-verify.md)).