@homeflare/alchemy 0.48.0 → 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 (97) 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/dist/talos/cluster-adapter-errors.d.ts.map +1 -1
  45. package/dist/talos/cluster-adapter.d.ts +2 -1
  46. package/dist/talos/cluster-adapter.d.ts.map +1 -1
  47. package/dist/talos/index.js +19 -8
  48. package/dist/talos/index.js.map +6 -6
  49. package/dist/talos/mint-temp-file.d.ts.map +1 -1
  50. package/dist/talos/talosctl.d.ts +29 -0
  51. package/dist/talos/talosctl.d.ts.map +1 -1
  52. package/docs/cloudflared-tunnel.md +136 -0
  53. package/docs/credentials.md +1 -1
  54. package/docs/ownership.md +3 -2
  55. package/docs/saas-oidc.md +185 -0
  56. package/docs/talos-openbao-adapter.md +3 -2
  57. package/docs/upstream-conformance-families.md +1 -1
  58. package/docs/upstream-conformance.md +3 -0
  59. package/package.json +1 -1
  60. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  61. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  62. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  63. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  64. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  65. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  66. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  67. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  68. package/src/cloudflare/fake-access.ts +196 -0
  69. package/src/cloudflare/fake-mesh.ts +6 -3
  70. package/src/cloudflare/fake-tunnel.ts +148 -0
  71. package/src/cloudflare/index.ts +27 -4
  72. package/src/cloudflare/providers.ts +16 -3
  73. package/src/cloudflare/saas-oidc-api.ts +224 -0
  74. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  75. package/src/cloudflare/saas-oidc-error.ts +52 -0
  76. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  77. package/src/cloudflare/saas-oidc-form.ts +190 -0
  78. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  79. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  80. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  81. package/src/cloudflare/saas-oidc-props.ts +147 -0
  82. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  83. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  84. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  85. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  86. package/src/cloudflare/saas-oidc-team.ts +70 -0
  87. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  88. package/src/cloudflare/saas-oidc.test.ts +201 -0
  89. package/src/cloudflare/saas-oidc.ts +107 -0
  90. package/src/talos/cluster-adapter-errors.ts +2 -1
  91. package/src/talos/cluster-adapter-move.test.ts +2 -0
  92. package/src/talos/cluster-adapter.ts +2 -1
  93. package/src/talos/mint-temp-file.test.ts +44 -0
  94. package/src/talos/mint-temp-file.ts +16 -4
  95. package/src/talos/talosctl-probe-kill.test.ts +56 -0
  96. package/src/talos/talosctl.test.ts +54 -1
  97. package/src/talos/talosctl.ts +34 -2
@@ -0,0 +1,147 @@
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
+ /**
14
+ * An OIDC flow Access can serve for the client: the five literals the SDK names
15
+ * (`AccessApplicationsCreateRequestSaasAppOIDCSaaSAppGrantTypesItem`, zero_trust.ts:8946-8951).
16
+ */
17
+ export type OidcGrantType =
18
+ | 'authorization_code'
19
+ | 'authorization_code_with_pkce'
20
+ | 'refresh_tokens'
21
+ | 'hybrid'
22
+ | 'implicit';
23
+
24
+ /** The user information the client may request from Access. */
25
+ export type OidcScope = 'openid' | 'email' | 'profile' | 'groups';
26
+
27
+ export interface SaasOidcApplicationProps {
28
+ /**
29
+ * Live Access application UUID. Set this when adopting a SaaS app that already exists. Without
30
+ * it the app is found by exact `name` among saas apps, which also works but refuses two matches.
31
+ */
32
+ readonly applicationId?: string;
33
+ /** The app's name. Defaults to a physical name derived from the logical id. */
34
+ readonly name?: string;
35
+ /**
36
+ * The Zero Trust team domain the issuer lives on, a bare hostname such as
37
+ * `example.cloudflareaccess.com`: no scheme, no path. Not read from the environment, so the
38
+ * issuer in the attributes is the one the declaration says.
39
+ */
40
+ readonly teamDomain: string;
41
+ /**
42
+ * How long Access sessions for this app last, in the API's duration format (`300ms` or `2h45m`).
43
+ * Written on create and on any sync; not drift-checked.
44
+ * @default '24h'
45
+ */
46
+ readonly sessionDuration?: string;
47
+ /**
48
+ * Ids of the identity providers users may pick for this app. Written on create and on any sync;
49
+ * not drift-checked.
50
+ * @default every IdP configured in the account (the field is left out of the write)
51
+ */
52
+ readonly allowedIdps?: ReadonlyArray<string>;
53
+ /**
54
+ * `true` makes users skip the identity-provider selection step; the API requires exactly one
55
+ * entry in `allowedIdps` then. Written on create and on any sync; not drift-checked.
56
+ * @default left out of the write (the API's default)
57
+ */
58
+ readonly autoRedirectToIdentity?: boolean;
59
+ /**
60
+ * Show the app in the App Launcher. Written on create and on any sync; not drift-checked.
61
+ * @default false
62
+ */
63
+ readonly appLauncherVisible?: boolean;
64
+ /**
65
+ * Ids of reusable Access policies, in ascending order of precedence (the SDK's own wording:
66
+ * zero_trust.ts:9774). The ORDER is part of the declaration: a reorder is drift and is synced.
67
+ */
68
+ readonly policies: ReadonlyArray<string>;
69
+ /** The OIDC client: the `saas_app` object of the Access application. */
70
+ readonly saasApp: {
71
+ /** The authentication protocol of the SaaS app. Only `oidc` is supported by this resource. */
72
+ readonly authType: 'oidc';
73
+ /**
74
+ * The permitted URLs for Cloudflare to return authorization codes and tokens to. Compared as
75
+ * a set (order is not significant to the API).
76
+ */
77
+ readonly redirectUris: ReadonlyArray<string>;
78
+ /** The user information shared with the client. Compared as a set. */
79
+ readonly scopes: ReadonlyArray<OidcScope>;
80
+ /** The OIDC flows the client may use. Compared as a set. */
81
+ readonly grantTypes: ReadonlyArray<OidcGrantType>;
82
+ /** Lifetime of the access token after creation: `m` or `h` units, `1m` to `24h` (the SDK field doc). */
83
+ readonly accessTokenLifetime: string;
84
+ /**
85
+ * How long a refresh token is valid after creation: `m`, `h` or `d` units, longer than `1m`
86
+ * (the SDK field doc). Needed for the `refresh_tokens` grant. Sent as `refreshTokenOptions.lifetime`.
87
+ * @default left out of the write
88
+ */
89
+ readonly refreshTokenLifetime?: string;
90
+ /**
91
+ * `true` makes this a PUBLIC client: the token endpoint does not require a client secret when
92
+ * the `authorization_code_with_pkce` grant is used, and then no secret exists to keep. Refused
93
+ * without that grant.
94
+ * @default false (a confidential client)
95
+ */
96
+ readonly allowPkceWithoutClientSecret?: boolean;
97
+ /**
98
+ * The URL the app's tile in the App Launcher sends users to. Written on create and on any
99
+ * sync; not drift-checked.
100
+ * @default left out of the write
101
+ */
102
+ readonly appLauncherUrl?: string;
103
+ };
104
+ }
105
+
106
+ export interface SaasOidcApplicationAttributes {
107
+ /** The Access application UUID. */
108
+ readonly applicationId: string;
109
+ /** The application audience tag, the `aud` of the Access JWTs for this app. */
110
+ readonly aud: string;
111
+ /** The `domain` Cloudflare reports for the app (the OIDC issuer without its scheme). */
112
+ readonly domain: string;
113
+ /** The app's name as Cloudflare reports it. */
114
+ readonly name: string;
115
+ /** The account the app lives in. */
116
+ readonly accountId: string;
117
+ /** The declared team domain (or, for state that predates the prop, the host Cloudflare reports). */
118
+ readonly teamDomain: string;
119
+ /** The OIDC client id, for the relying party's configuration. */
120
+ readonly clientId: string;
121
+ /** `https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`. */
122
+ readonly issuer: string;
123
+ /** `<issuer>/authorization`. */
124
+ readonly authorizationEndpoint: string;
125
+ /** `<issuer>/token`. */
126
+ readonly tokenEndpoint: string;
127
+ /** The signing keys, for a verifier that validates ID tokens. */
128
+ readonly jwksEndpoint: string;
129
+ /** `<issuer>/userinfo`. */
130
+ readonly userinfoEndpoint: string;
131
+ /** `<issuer>/.well-known/openid-configuration`. */
132
+ readonly configurationEndpoint: string;
133
+ /** The redirect URIs the live app permits. */
134
+ readonly redirectUris: ReadonlyArray<string>;
135
+ /** The scopes the live app serves. */
136
+ readonly scopes: ReadonlyArray<string>;
137
+ /** The grant types the live app allows. */
138
+ readonly grantTypes: ReadonlyArray<string>;
139
+ /** The live access token lifetime, or `''` when Cloudflare reports none. */
140
+ readonly accessTokenLifetime: string;
141
+ /** The live refresh token lifetime, or `''` when Cloudflare reports none. */
142
+ readonly refreshTokenLifetime: string;
143
+ /** When Cloudflare created the app, if it says. */
144
+ readonly createdAt: string | undefined;
145
+ /** When Cloudflare last updated the app, if it says. */
146
+ readonly updatedAt: string | undefined;
147
+ }
@@ -0,0 +1,85 @@
1
+ /** SaasOidcApplicationProvider's read, delete and adoption paths against the fake Access API. */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import { Unowned } from 'alchemy/AdoptPolicy';
4
+ import * as Effect from 'effect/Effect';
5
+ import { TEAM, fakeAccess } from './fake-access.ts';
6
+ import { FAKE_ACCOUNT, fakeClientLayer } from './fake-mesh.ts';
7
+ import { deleteSaasOidc } from './saas-oidc-lifecycle.ts';
8
+ import { LIVE_OIDC, publicClient, read, reconcile, run, writes } from './saas-oidc-harness.ts';
9
+
10
+ describe('read', () => {
11
+ test('finds a cold app by exact name across pages and returns it Unowned', async () => {
12
+ const fake = fakeAccess();
13
+ fake.seed({ name: 'Headlamp-old' });
14
+ fake.seed({ name: 'Other', type: 'self_hosted' });
15
+ const live = fake.seed({ name: 'Headlamp', policies: ['policy-admin'], saas_app: LIVE_OIDC });
16
+ const probe = await run(fake, (p) => read(p, publicClient()));
17
+ expect(Unowned.is(probe)).toBe(true);
18
+ expect(probe).toMatchObject({
19
+ applicationId: live.id,
20
+ clientId: live.saas_app?.['client_id'],
21
+ accountId: FAKE_ACCOUNT,
22
+ redirectUris: ['https://headlamp.example.test/oidc-callback'],
23
+ });
24
+ });
25
+
26
+ test('an owned row refreshes by id and is not Unowned', async () => {
27
+ const fake = fakeAccess();
28
+ const [created, refreshed] = await run(fake, (p) =>
29
+ Effect.gen(function* () {
30
+ const made = yield* reconcile(p, publicClient());
31
+ return [made, yield* read(p, publicClient(), made)] as const;
32
+ }),
33
+ );
34
+ expect(Unowned.is(refreshed)).toBe(false);
35
+ expect(refreshed).toEqual(created);
36
+ });
37
+
38
+ test('an owned row whose app is gone reads undefined, not a same-named app', async () => {
39
+ const fake = fakeAccess();
40
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
41
+ fake.apps.delete(made.applicationId);
42
+ fake.seed({ name: 'Headlamp', policies: ['policy-admin'], saas_app: LIVE_OIDC });
43
+ expect(await run(fake, (p) => read(p, publicClient(), made))).toBeUndefined();
44
+ });
45
+
46
+ test('adopts by applicationId from state that predates the teamDomain prop', async () => {
47
+ const fake = fakeAccess();
48
+ const live = fake.seed({ name: 'OpenBao', policies: ['policy-admin'], saas_app: LIVE_OIDC });
49
+ const { teamDomain: _team, ...before } = publicClient({
50
+ applicationId: live.id,
51
+ name: 'OpenBao',
52
+ });
53
+ const probe = await run(fake, (p) => read(p, before as never));
54
+ expect(Unowned.is(probe)).toBe(true);
55
+ expect(probe).toMatchObject({ applicationId: live.id, teamDomain: TEAM });
56
+ });
57
+
58
+ test('adopting a matching live app is a no-op: zero writes', async () => {
59
+ const fake = fakeAccess();
60
+ const live = fake.seed({ name: 'Headlamp', policies: ['policy-admin'], saas_app: LIVE_OIDC });
61
+ const out = await run(fake, (p) => reconcile(p, publicClient({ applicationId: live.id })));
62
+ expect(out.applicationId).toBe(live.id);
63
+ expect(writes(fake)).toEqual([]);
64
+ });
65
+
66
+ test('answers undefined when nothing matches', async () => {
67
+ const fake = fakeAccess();
68
+ expect(await run(fake, (p) => read(p, publicClient()))).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe('delete', () => {
73
+ test('removes the app, and a second delete of the same app is a success', async () => {
74
+ const fake = fakeAccess();
75
+ const out = await run(fake, (p) => reconcile(p, publicClient()));
76
+ await Effect.runPromise(
77
+ Effect.gen(function* () {
78
+ yield* deleteSaasOidc(out);
79
+ yield* deleteSaasOidc(out);
80
+ }).pipe(Effect.provide(fakeClientLayer(fake))),
81
+ );
82
+ expect(fake.apps.size).toBe(0);
83
+ expect(writes(fake)).toEqual(['POST', 'DELETE', 'DELETE']);
84
+ });
85
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The attributes a consumer of a SaasOidcApplication reads through `app.issuer` must stay
3
+ * resolved when the app itself is UPDATED, or every consumer re-plans (the Talos
4
+ * KubeAuthenticationConfig would restart kube-apiserver for a change that cannot move the issuer).
5
+ * Alchemy's plan keeps only the provider's `stables` resolved for an updated resource (Plan.ts
6
+ * `withStables`, beta.81), so this runs the real engine with a downstream resource and counts its
7
+ * reconciles.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import * as Effect from 'effect/Effect';
11
+ import { fakeAccess } from './fake-access.ts';
12
+ import { IssuerConsumer, consumerProvider } from './saas-oidc-consumer.ts';
13
+ import { engine, failureOf, publicClient, writes } from './saas-oidc-harness.ts';
14
+ import { SaasOidcApplication } from './saas-oidc.ts';
15
+
16
+ const declare = (redirect: string) =>
17
+ Effect.gen(function* () {
18
+ const base = publicClient();
19
+ const app = yield* SaasOidcApplication('headlamp', {
20
+ ...base,
21
+ saasApp: { ...base.saasApp, redirectUris: [redirect] },
22
+ });
23
+ yield* IssuerConsumer('kube-auth', { issuer: app.issuer, jwks: app.jwksEndpoint });
24
+ });
25
+
26
+ describe('consumers of an updated SaasOidcApplication', () => {
27
+ test('a redirect-URI update does not re-plan a resource that reads the issuer', async () => {
28
+ const fake = fakeAccess();
29
+ const consumer = consumerProvider();
30
+ const stack = engine(fake, consumer.layer);
31
+ expect(failureOf(await stack.deploy(declare('https://a.example.test/cb')))).toBe('');
32
+ expect(consumer.reconciled).toHaveLength(1);
33
+ expect(failureOf(await stack.deploy(declare('https://b.example.test/cb')))).toBe('');
34
+ expect(writes(fake)).toEqual(['POST', 'PUT']);
35
+ expect(consumer.reconciled).toHaveLength(1);
36
+ expect(consumer.reconciled[0]).toContain('/cdn-cgi/access/sso/oidc/');
37
+ });
38
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SaasOidcApplication through Alchemy's real plan/apply (`engine` in saas-oidc-harness.ts) over the
3
+ * fake Access API. The engine owns state keys, removal policy and what gets persisted, so the
4
+ * handler-level tests beside this one cannot see them.
5
+ *
6
+ * ⛔ WHAT THESE PIN:
7
+ * - NO CLIENT SECRET IS EVER PERSISTED: the serialised state rows (props and attributes) do not
8
+ * contain the secret the fake's create response hands out.
9
+ * - STATE-KEY STABILITY: a row written by the openbao copy of this resource (same type id, props
10
+ * and attributes without `teamDomain`) is UPDATED IN PLACE by this one — no create, no delete,
11
+ * the same application id and client id. A renamed type id would plan a second app.
12
+ * - `retain` is the default: dropping the declaration leaves the app live and sends no DELETE.
13
+ */
14
+ import { describe, expect, test } from 'bun:test';
15
+ import { adopt } from 'alchemy/AdoptPolicy';
16
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
17
+ import * as Effect from 'effect/Effect';
18
+ import { FAKE_CLIENT_SECRET, TEAM, fakeAccess } from './fake-access.ts';
19
+ import { FAKE_ACCOUNT } from './fake-mesh.ts';
20
+ import { LIVE_OIDC, engine, failureOf, publicClient, writes } from './saas-oidc-harness.ts';
21
+ import { SaasOidcApplication } from './saas-oidc.ts';
22
+
23
+ const ID = 'headlamp';
24
+ const declare = (over = {}) => SaasOidcApplication(ID, publicClient(over));
25
+
26
+ describe('SaasOidcApplication (the real engine)', () => {
27
+ test('state holds no client secret, and is keyed by the pinned type id', async () => {
28
+ const fake = fakeAccess();
29
+ const stack = engine(fake);
30
+ expect(failureOf(await stack.deploy(declare()))).toBe('');
31
+ const row = stack.rows['oidc']?.['test']?.[ID];
32
+ expect(row?.resourceType).toBe('HomeFlare.Access.SaasOidcApplication');
33
+ expect(row?.status).toBe('created');
34
+ expect(JSON.stringify(stack.rows)).not.toContain(FAKE_CLIENT_SECRET);
35
+ expect(JSON.stringify(stack.rows)).not.toContain('client_secret');
36
+ expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
37
+ expect(row?.attr).toMatchObject({ teamDomain: TEAM, accountId: FAKE_ACCOUNT });
38
+ });
39
+
40
+ test('a second identical deploy writes nothing', async () => {
41
+ const fake = fakeAccess();
42
+ const stack = engine(fake);
43
+ await stack.deploy(declare());
44
+ expect(failureOf(await stack.deploy(declare()))).toBe('');
45
+ expect(writes(fake)).toEqual(['POST']);
46
+ });
47
+
48
+ test('a row written by the openbao copy is updated in place, never replaced', async () => {
49
+ const fake = fakeAccess();
50
+ const live = fake.seed({
51
+ name: 'Headlamp',
52
+ policies: ['policy-admin'],
53
+ saas_app: LIVE_OIDC,
54
+ });
55
+ const clientId = String(live.saas_app?.['client_id']);
56
+ const stack = engine(fake);
57
+ const { teamDomain: _team, ...oldProps } = publicClient();
58
+ const issuer = `https://${TEAM}/cdn-cgi/access/sso/oidc/${clientId}`;
59
+ stack.rows['oidc'] = {
60
+ test: {
61
+ [ID]: {
62
+ resourceType: 'HomeFlare.Access.SaasOidcApplication',
63
+ namespace: undefined,
64
+ fqn: ID,
65
+ logicalId: ID,
66
+ instanceId: 'i-old',
67
+ providerVersion: 0,
68
+ status: 'created',
69
+ downstream: [],
70
+ bindings: [],
71
+ props: oldProps,
72
+ attr: {
73
+ applicationId: live.id,
74
+ aud: live.aud,
75
+ clientId,
76
+ accountId: FAKE_ACCOUNT,
77
+ name: 'Headlamp',
78
+ domain: issuer.replace('https://', ''),
79
+ issuer,
80
+ },
81
+ },
82
+ },
83
+ };
84
+ expect(failureOf(await stack.deploy(declare()))).toBe('');
85
+ expect(writes(fake)).toEqual([]);
86
+ expect(fake.apps.size).toBe(1);
87
+ expect(stack.rows['oidc']?.['test']?.[ID]?.attr).toMatchObject({
88
+ applicationId: live.id,
89
+ clientId,
90
+ teamDomain: TEAM,
91
+ jwksEndpoint: `${issuer}/jwks`,
92
+ });
93
+ });
94
+
95
+ test('retain is the default: dropping the declaration deletes nothing', async () => {
96
+ const fake = fakeAccess();
97
+ const stack = engine(fake);
98
+ await stack.deploy(declare());
99
+ expect(failureOf(await stack.deploy(Effect.void))).toBe('');
100
+ expect(fake.apps.size).toBe(1);
101
+ expect(writes(fake)).toEqual(['POST']);
102
+ });
103
+
104
+ test('RemovalPolicy.destroy() still reaches the implemented delete', async () => {
105
+ const fake = fakeAccess();
106
+ const stack = engine(fake);
107
+ await stack.deploy(declare().pipe(RemovalPolicy.destroy()));
108
+ expect(failureOf(await stack.deploy(Effect.void))).toBe('');
109
+ expect(fake.apps.size).toBe(0);
110
+ expect(writes(fake)).toEqual(['POST', 'DELETE']);
111
+ });
112
+
113
+ test('a live app is not taken over without adopt(true), and is with it', async () => {
114
+ const fake = fakeAccess();
115
+ fake.seed({
116
+ name: 'Headlamp',
117
+ policies: ['policy-admin'],
118
+ saas_app: LIVE_OIDC,
119
+ });
120
+ const refused = engine(fake);
121
+ expect(failureOf(await refused.deploy(declare()))).not.toBe('');
122
+ expect(writes(fake)).toEqual([]);
123
+ const taken = engine(fake);
124
+ expect(failureOf(await taken.deploy(declare().pipe(adopt(true))))).toBe('');
125
+ expect(writes(fake)).toEqual([]);
126
+ expect(taken.status(ID)).toBe('updated');
127
+ });
128
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The team-domain check before a FIRST create (saas-oidc-team.ts): a wrong `teamDomain` is refused
3
+ * with no write when the account's Zero Trust organization can be read, and where it cannot be read
4
+ * the create goes on as it always did, leaving the documented orphan (docs/saas-oidc.md).
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { type FakeAccess, TEAM, fakeAccess } from './fake-access.ts';
9
+ import { publicClient, reconcile, run, writes } from './saas-oidc-harness.ts';
10
+
11
+ const orgReads = (fake: FakeAccess) =>
12
+ fake.seen.filter((s) => s.method === 'GET' && s.path.endsWith('/access/organizations')).length;
13
+
14
+ describe('a first create checks the declared team against the organization', () => {
15
+ test('a different organization domain is refused before any write', async () => {
16
+ const fake = fakeAccess({ org: { authDomain: 'real.cloudflareaccess.com' } });
17
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
18
+ expect(String(failure)).toContain('real.cloudflareaccess.com');
19
+ expect(String(failure)).toContain(TEAM);
20
+ expect(String(failure)).toContain('Refused before any write');
21
+ expect(writes(fake)).toEqual([]);
22
+ expect(fake.apps.size).toBe(0);
23
+ });
24
+
25
+ test('the same domain, in any case, creates the app after one organization read', async () => {
26
+ const fake = fakeAccess({ org: { authDomain: TEAM.toUpperCase() } });
27
+ const out = await run(fake, (p) => reconcile(p, publicClient()));
28
+ expect(out.teamDomain).toBe(TEAM);
29
+ expect(orgReads(fake)).toBe(1);
30
+ expect(writes(fake)).toEqual(['POST']);
31
+ });
32
+
33
+ test('the organization is read before the create and not again for an existing app', async () => {
34
+ const fake = fakeAccess();
35
+ const [first] = await run(fake, (p) =>
36
+ Effect.gen(function* () {
37
+ const created = yield* reconcile(p, publicClient());
38
+ yield* reconcile(p, publicClient(), created);
39
+ return [created] as const;
40
+ }),
41
+ );
42
+ expect(first.teamDomain).toBe(TEAM);
43
+ expect(orgReads(fake)).toBe(1);
44
+ const order = fake.seen.map((s) => (s.path.endsWith('/organizations') ? 'org' : s.method));
45
+ expect(order.indexOf('org')).toBeLessThan(order.indexOf('POST'));
46
+ });
47
+
48
+ test('an owned row whose app vanished is a create again, so it checks again', async () => {
49
+ const fake = fakeAccess();
50
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
51
+ fake.apps.delete(made.applicationId);
52
+ await run(fake, (p) => reconcile(p, publicClient(), made));
53
+ expect(orgReads(fake)).toBe(2);
54
+ });
55
+
56
+ test('an update to an existing app never reads the organization', async () => {
57
+ const fake = fakeAccess();
58
+ const live = fake.seed({ name: 'Headlamp', policies: ['policy-old'] });
59
+ await run(fake, (p) => reconcile(p, publicClient({ applicationId: live.id })));
60
+ expect(writes(fake)).toEqual(['PUT']);
61
+ expect(orgReads(fake)).toBe(0);
62
+ });
63
+ });
64
+
65
+ describe('where the organization cannot be read, the create goes on (the known limit)', () => {
66
+ test.each(['unauthorized', 'forbidden', 'not_found'] as const)(
67
+ '%s: one attempt, then the create proceeds with a right team',
68
+ async (org) => {
69
+ const fake = fakeAccess({ org });
70
+ const out = await run(fake, (p) => reconcile(p, publicClient()));
71
+ expect(out.teamDomain).toBe(TEAM);
72
+ // ★ Retry.none: the SDK's default policy would retry "Authentication error" eight times.
73
+ expect(orgReads(fake)).toBe(1);
74
+ expect(writes(fake)).toEqual(['POST']);
75
+ },
76
+ );
77
+
78
+ test('a wrong team still writes the app, and the post-write check then fails the run', async () => {
79
+ const fake = fakeAccess({ team: 'real.cloudflareaccess.com', org: 'forbidden' });
80
+ const failure = await run(fake, (p) =>
81
+ Effect.flip(reconcile(p, publicClient({ teamDomain: 'other.cloudflareaccess.com' }))),
82
+ );
83
+ expect(String(failure)).toContain('Cloudflare reports the app on "real.cloudflareaccess.com"');
84
+ // The documented orphan: a live app, and no state row for it (docs/saas-oidc.md).
85
+ expect(writes(fake)).toEqual(['POST']);
86
+ expect(fake.apps.size).toBe(1);
87
+ });
88
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The team-domain check that runs BEFORE a first create, so a wrong `teamDomain` is refused with no
3
+ * write instead of after one (saas-oidc-lifecycle.ts says where it is called).
4
+ *
5
+ * ★ THE ACCOUNT'S TEAM DOMAIN CAN BE READ BEFORE ANY APP EXISTS: `GET /accounts/{account_id}/access/
6
+ * organizations` returns the Zero Trust organization, whose `auth_domain` is "The unique subdomain
7
+ * assigned to your Zero Trust organization" (`@distilled.cloud/cloudflare@1.0.0-rc.13`
8
+ * `listOrganizationsForAccount`, zero_trust.ts:168990 for the path and :238313-238324 for the
9
+ * operation; `ListOrganizationsResponse.authDomain`, :169161). Alchemy's own
10
+ * `Cloudflare/Access/Organization.ts` (beta.81, `observe`, line 338) reads it the same way. The
11
+ * app's OIDC issuer host is that same domain, which is what `checkTeam` already compares after
12
+ * the write.
13
+ * ⚠️ BUT IT IS A DIFFERENT API-TOKEN PERMISSION than the one that writes the app. Cloudflare's docs
14
+ * (developers.cloudflare.com/fundamentals/api/reference/permissions, read 2026-10-09) give
15
+ * `Access: Organizations Read` (or `Access: Organizations, Identity Providers, and Groups Read`)
16
+ * for this endpoint, while creating an Access application needs `Access: Apps and Policies
17
+ * Write` (the same docs, e.g. cloudflare-one/access-controls/ai-controls/secure-mcp-servers). So
18
+ * a token minted only for the app write may be refused here.
19
+ * ★ THEREFORE THE CHECK IS ADVISORY WHERE IT CANNOT READ, AND STRICT WHERE IT CAN. Refused only when
20
+ * the organization was read and names a different domain. A token that may not read it
21
+ * (`Unauthorized`, `Forbidden`) or an account with no organization (`OrganizationNotFound`)
22
+ * gives no answer, and the create goes on exactly as before, with the post-write `checkTeam`
23
+ * still the backstop: that is the documented known limit (docs/saas-oidc.md). Any other failure
24
+ * (a throttle, a 5xx) fails the run here, before a write; run again.
25
+ * ★ ONE ATTEMPT (`Retry.none`, retry.ts:48-51). The SDK's default policy retries "Authentication
26
+ * error", which is what a token without the permission gets, up to eight times with backoff
27
+ * (core retry.ts `makeDefault`): a first create would wait for an answer it can never get.
28
+ */
29
+ import * as Retry from '@distilled.cloud/cloudflare/Retry';
30
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
31
+ import * as Effect from 'effect/Effect';
32
+ import { SaasOidcError, withheld } from './saas-oidc-error.ts';
33
+
34
+ /**
35
+ * ⚠️ WHY A TAG PREDICATE AND NOT `catchTags` for these three. `Forbidden` is built by the SDK's 4xx
36
+ * mapping (protocol.ts:164-169 for code 10001, :423-436 for a bare 403) but is not in this
37
+ * operation's declared error union (it lists `Unauthorized`, `OrganizationNotFound`, … only), so
38
+ * `catchTags({ Forbidden })` does not typecheck. Which of `Unauthorized` (code 10000 "Authentication
39
+ * error", protocol.ts:155-158) and `Forbidden` (10001 "Method not allowed for token") a token
40
+ * without the permission gets was not measured live, so both stand. Tag equality only: no status,
41
+ * no message, as `retryTransient` in saas-oidc-api.ts does.
42
+ */
43
+ const cannotRead = (error: { readonly _tag: string }): boolean =>
44
+ error._tag === 'Unauthorized' ||
45
+ error._tag === 'Forbidden' ||
46
+ error._tag === 'OrganizationNotFound';
47
+
48
+ /** The organization's `auth_domain`, or `undefined` when this token cannot read it or there is none. */
49
+ const readAuthDomain = (accountId: string) =>
50
+ zeroTrust.listOrganizationsForAccount({ accountId }).pipe(
51
+ Retry.none,
52
+ Effect.map((organization) => organization.authDomain ?? undefined),
53
+ Effect.catchTag('CloudflareParseError', withheld('organization read')),
54
+ Effect.catchIf(cannotRead, () => Effect.succeed(undefined)),
55
+ );
56
+
57
+ /**
58
+ * A refusal when the account's organization is read and its domain is not the declared one; else
59
+ * `undefined` (it matches, or it could not be read, see above). Hostnames compare case-insensitively.
60
+ */
61
+ export const checkTeamBeforeCreate = (accountId: string, teamDomain: string) =>
62
+ readAuthDomain(accountId).pipe(
63
+ Effect.map((authDomain) =>
64
+ authDomain === undefined || authDomain.toLowerCase() === teamDomain.toLowerCase()
65
+ ? undefined
66
+ : new SaasOidcError({
67
+ message: `SaasOidcApplication declares teamDomain "${teamDomain}" but this account's Zero Trust organization is "${authDomain}". Refused before any write; no application was created.`,
68
+ }),
69
+ ),
70
+ );
@@ -0,0 +1,116 @@
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
+
9
+ /** The OIDC half of a live SaaS app, from the wire JSON. */
10
+ export interface ObservedSaas {
11
+ readonly authType: string | undefined;
12
+ readonly clientId: string | undefined;
13
+ readonly redirectUris: ReadonlyArray<string> | undefined;
14
+ readonly scopes: ReadonlyArray<string> | undefined;
15
+ readonly grantTypes: ReadonlyArray<string> | undefined;
16
+ readonly accessTokenLifetime: string | undefined;
17
+ readonly refreshTokenLifetime: string | undefined;
18
+ readonly allowPkceWithoutClientSecret: boolean | undefined;
19
+ }
20
+
21
+ /** A live Access application as this package sees it. ⛔ There is no secret field, on purpose. */
22
+ export interface ObservedApp {
23
+ readonly id: string | undefined;
24
+ readonly aud: string | undefined;
25
+ readonly type: string | undefined;
26
+ readonly name: string | undefined;
27
+ readonly domain: string | undefined;
28
+ readonly createdAt: string | undefined;
29
+ readonly updatedAt: string | undefined;
30
+ readonly policyIds: ReadonlyArray<string>;
31
+ readonly saasApp: ObservedSaas | undefined;
32
+ }
33
+
34
+ /** What a create or an update sends. Built by `writeBody` in saas-oidc-form.ts. */
35
+ export interface AppWrite {
36
+ readonly name: string;
37
+ readonly sessionDuration: string;
38
+ readonly allowedIdps: ReadonlyArray<string> | undefined;
39
+ readonly autoRedirectToIdentity: boolean | undefined;
40
+ readonly appLauncherVisible: boolean;
41
+ readonly policies: ReadonlyArray<string>;
42
+ readonly saasApp: {
43
+ readonly authType: 'oidc';
44
+ readonly redirectUris: ReadonlyArray<string>;
45
+ readonly scopes: ReadonlyArray<string>;
46
+ readonly grantTypes: ReadonlyArray<string>;
47
+ readonly accessTokenLifetime: string;
48
+ readonly refreshTokenOptions?: { readonly lifetime: string };
49
+ readonly allowPkceWithoutClientSecret: boolean;
50
+ readonly appLauncherUrl: string | undefined;
51
+ };
52
+ }
53
+
54
+ const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
55
+ const bool = (value: unknown): boolean | undefined =>
56
+ typeof value === 'boolean' ? value : undefined;
57
+ const strings = (value: unknown): ReadonlyArray<string> | undefined =>
58
+ Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
59
+ export const record = (value: unknown): Record<string, unknown> | undefined =>
60
+ typeof value === 'object' && value !== null && !Array.isArray(value)
61
+ ? (value as Record<string, unknown>)
62
+ : undefined;
63
+
64
+ /**
65
+ * The policy ids in the order Access applies them. Each policy on the wire carries a `precedence`
66
+ * ("The order of execution for this policy", zero_trust.ts:13630-13631), and the declared list is
67
+ * "in ascending order of precedence" (zero_trust.ts:9774). Sorted by it only when EVERY entry
68
+ * reports one (ties keep array order); otherwise the array order stands, since a partial ranking
69
+ * says nothing about where the unranked entries sit.
70
+ */
71
+ const policyIds = (policies: ReadonlyArray<unknown>): ReadonlyArray<string> => {
72
+ const entries = policies.flatMap((policy, index) => {
73
+ const id = str(record(policy)?.['id']);
74
+ const precedence = record(policy)?.['precedence'];
75
+ return id === undefined
76
+ ? []
77
+ : [{ id, index, precedence: typeof precedence === 'number' ? precedence : undefined }];
78
+ });
79
+ if (!entries.every((entry) => entry.precedence !== undefined)) return entries.map((e) => e.id);
80
+ return [...entries]
81
+ .sort((a, b) => (a.precedence ?? 0) - (b.precedence ?? 0) || a.index - b.index)
82
+ .map((entry) => entry.id);
83
+ };
84
+
85
+ /**
86
+ * Named fields only, from Cloudflare's snake_case JSON. ⛔ `client_secret` is not among them, and
87
+ * `public_key`, `custom_claims` and `hybrid_and_implicit_options` are not used by this resource.
88
+ */
89
+ export const parseApp = (raw: Record<string, unknown>): ObservedApp => {
90
+ const saas = record(raw['saas_app']);
91
+ const refresh = record(saas?.['refresh_token_options']);
92
+ const policies = Array.isArray(raw['policies']) ? raw['policies'] : [];
93
+ return {
94
+ id: str(raw['id']),
95
+ aud: str(raw['aud']),
96
+ type: str(raw['type']),
97
+ name: str(raw['name']),
98
+ domain: str(raw['domain']),
99
+ createdAt: str(raw['created_at']),
100
+ updatedAt: str(raw['updated_at']),
101
+ policyIds: policyIds(policies),
102
+ saasApp:
103
+ saas === undefined
104
+ ? undefined
105
+ : {
106
+ authType: str(saas['auth_type']),
107
+ clientId: str(saas['client_id']),
108
+ redirectUris: strings(saas['redirect_uris']),
109
+ scopes: strings(saas['scopes']),
110
+ grantTypes: strings(saas['grant_types']),
111
+ accessTokenLifetime: str(saas['access_token_lifetime']),
112
+ refreshTokenLifetime: str(refresh?.['lifetime']),
113
+ allowPkceWithoutClientSecret: bool(saas['allow_pkce_without_client_secret']),
114
+ },
115
+ };
116
+ };