@homeflare/alchemy 0.48.1 → 0.50.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 (126) 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/kubernetes/helm-chart-bounded.d.ts +44 -0
  45. package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
  46. package/dist/kubernetes/index.d.ts +12 -0
  47. package/dist/kubernetes/index.d.ts.map +1 -0
  48. package/dist/kubernetes/index.js +585 -0
  49. package/dist/kubernetes/index.js.map +19 -0
  50. package/dist/kubernetes/ready-checks.d.ts +45 -0
  51. package/dist/kubernetes/ready-checks.d.ts.map +1 -0
  52. package/dist/kubernetes/ready-classify.d.ts +23 -0
  53. package/dist/kubernetes/ready-classify.d.ts.map +1 -0
  54. package/dist/kubernetes/ready-connect.d.ts +29 -0
  55. package/dist/kubernetes/ready-connect.d.ts.map +1 -0
  56. package/dist/kubernetes/ready-declare.d.ts +5 -0
  57. package/dist/kubernetes/ready-declare.d.ts.map +1 -0
  58. package/dist/kubernetes/ready-errors.d.ts +80 -0
  59. package/dist/kubernetes/ready-errors.d.ts.map +1 -0
  60. package/dist/kubernetes/ready-poll.d.ts +61 -0
  61. package/dist/kubernetes/ready-poll.d.ts.map +1 -0
  62. package/dist/kubernetes/ready.d.ts +74 -0
  63. package/dist/kubernetes/ready.d.ts.map +1 -0
  64. package/dist/talos/fake-apiserver.d.ts +11 -2
  65. package/dist/talos/fake-apiserver.d.ts.map +1 -1
  66. package/docs/cloudflared-tunnel.md +136 -0
  67. package/docs/credentials.md +1 -1
  68. package/docs/kubernetes-ready.md +146 -0
  69. package/docs/ownership.md +3 -2
  70. package/docs/peers.md +13 -0
  71. package/docs/provider-standard.md +7 -0
  72. package/docs/saas-oidc.md +185 -0
  73. package/docs/upstream-conformance-families.md +1 -1
  74. package/docs/upstream-conformance.md +3 -0
  75. package/package.json +7 -1
  76. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  77. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  78. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  79. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  80. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  81. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  82. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  83. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  84. package/src/cloudflare/fake-access.ts +196 -0
  85. package/src/cloudflare/fake-mesh.ts +6 -3
  86. package/src/cloudflare/fake-tunnel.ts +148 -0
  87. package/src/cloudflare/index.ts +27 -4
  88. package/src/cloudflare/providers.ts +16 -3
  89. package/src/cloudflare/saas-oidc-api.ts +224 -0
  90. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  91. package/src/cloudflare/saas-oidc-error.ts +52 -0
  92. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  93. package/src/cloudflare/saas-oidc-form.ts +190 -0
  94. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  95. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  96. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  97. package/src/cloudflare/saas-oidc-props.ts +147 -0
  98. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  99. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  100. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  101. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  102. package/src/cloudflare/saas-oidc-team.ts +70 -0
  103. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  104. package/src/cloudflare/saas-oidc.test.ts +201 -0
  105. package/src/cloudflare/saas-oidc.ts +107 -0
  106. package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
  107. package/src/kubernetes/helm-chart-bounded.ts +110 -0
  108. package/src/kubernetes/index.ts +28 -0
  109. package/src/kubernetes/ready-backoff.test.ts +124 -0
  110. package/src/kubernetes/ready-checks.test.ts +106 -0
  111. package/src/kubernetes/ready-checks.ts +165 -0
  112. package/src/kubernetes/ready-classify.ts +76 -0
  113. package/src/kubernetes/ready-connect.ts +52 -0
  114. package/src/kubernetes/ready-declare.test.ts +85 -0
  115. package/src/kubernetes/ready-declare.ts +50 -0
  116. package/src/kubernetes/ready-errors.ts +102 -0
  117. package/src/kubernetes/ready-first-install.test.ts +126 -0
  118. package/src/kubernetes/ready-poll-states.test.ts +92 -0
  119. package/src/kubernetes/ready-poll.test.ts +194 -0
  120. package/src/kubernetes/ready-poll.ts +215 -0
  121. package/src/kubernetes/ready-rollout.test.ts +58 -0
  122. package/src/kubernetes/ready-secrecy.test.ts +131 -0
  123. package/src/kubernetes/ready.fixtures.ts +103 -0
  124. package/src/kubernetes/ready.test.ts +205 -0
  125. package/src/kubernetes/ready.ts +188 -0
  126. package/src/talos/fake-apiserver.ts +21 -3
@@ -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
+ };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * SaasOidcApplicationProvider against a fake Access API (fake-access.ts), its handlers called the
3
+ * way the engine calls them. Read, delete and adoption: saas-oidc-read.test.ts.
4
+ *
5
+ * ⛔ THE ASSERTIONS THAT MATTER MOST: a public PKCE client is created with
6
+ * `allow_pkce_without_client_secret` and the `authorization_code_with_pkce` grant, and the client
7
+ * secret — which the fake's create response DOES hand out, as the API does on POST — appears in
8
+ * no attribute, no log line and no request body.
9
+ */
10
+ import { afterEach, describe, expect, spyOn, test } from 'bun:test';
11
+ import type { StackServices } from 'alchemy/Stack';
12
+ import * as Effect from 'effect/Effect';
13
+ import type * as Layer from 'effect/Layer';
14
+ import { FAKE_CLIENT_SECRET, TEAM, fakeAccess } from './fake-access.ts';
15
+ import { FAKE_ACCOUNT } from './fake-mesh.ts';
16
+ import { providers } from './providers.ts';
17
+ import { LIVE_OIDC, publicClient, reconcile, run, writes } from './saas-oidc-harness.ts';
18
+ import { SaasOidcApplication } from './saas-oidc.ts';
19
+
20
+ // ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`. A provider with
21
+ // an open error channel leaks it here, and `bun run types` catches that.
22
+ type Built = Layer.Success<ReturnType<typeof providers>>;
23
+ const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
24
+ void _stackShaped;
25
+
26
+ const postBody = (fake: ReturnType<typeof fakeAccess>) =>
27
+ fake.seen.find((s) => s.method === 'POST')?.body as Record<string, unknown>;
28
+
29
+ describe('SaasOidcApplication identity', () => {
30
+ test('the type id is the one the openbao state rows are keyed by', () => {
31
+ expect(SaasOidcApplication.Type).toBe('HomeFlare.Access.SaasOidcApplication');
32
+ });
33
+ });
34
+
35
+ describe('create', () => {
36
+ const debug = process.env['DISTILLED_DEBUG_HTTP'];
37
+ afterEach(() => {
38
+ if (debug === undefined) delete process.env['DISTILLED_DEBUG_HTTP'];
39
+ else process.env['DISTILLED_DEBUG_HTTP'] = debug;
40
+ });
41
+
42
+ test('a public client is sent as PKCE with no secret, and read back for its client id', async () => {
43
+ const fake = fakeAccess();
44
+ const spies = [spyOn(console, 'log'), spyOn(console, 'error'), spyOn(console, 'warn')];
45
+ const out = await run(fake, (p) => reconcile(p, publicClient()));
46
+ const logged = JSON.stringify(spies.map((spy) => spy.mock.calls));
47
+ for (const spy of spies) spy.mockRestore();
48
+ expect(postBody(fake)).toMatchObject({
49
+ type: 'saas',
50
+ name: 'Headlamp',
51
+ policies: ['policy-admin'],
52
+ saas_app: {
53
+ auth_type: 'oidc',
54
+ grant_types: ['authorization_code_with_pkce'],
55
+ allow_pkce_without_client_secret: true,
56
+ access_token_lifetime: '15m',
57
+ },
58
+ });
59
+ expect(writes(fake)).toEqual(['POST']);
60
+ expect(out).toMatchObject({ accountId: FAKE_ACCOUNT, teamDomain: TEAM, name: 'Headlamp' });
61
+ expect(out.issuer).toBe(`https://${TEAM}/cdn-cgi/access/sso/oidc/${out.clientId}`);
62
+ expect(out.jwksEndpoint).toBe(`${out.issuer}/jwks`);
63
+ // ⛔ The secret was in the create response. It is nowhere we control.
64
+ expect(JSON.stringify(out)).not.toContain(FAKE_CLIENT_SECRET);
65
+ expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
66
+ expect(logged).not.toContain(FAKE_CLIENT_SECRET);
67
+ expect(Object.keys(out)).not.toContain('clientSecret');
68
+ });
69
+
70
+ test('refuses while DISTILLED_DEBUG_HTTP is set, before any write', async () => {
71
+ process.env['DISTILLED_DEBUG_HTTP'] = '1';
72
+ const fake = fakeAccess();
73
+ // The SDK prints the name lookup's (secret-free) response while the variable is set.
74
+ const quiet = spyOn(console, 'error').mockImplementation(() => {});
75
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
76
+ quiet.mockRestore();
77
+ expect(String(failure)).toContain('DISTILLED_DEBUG_HTTP');
78
+ expect(writes(fake)).toEqual([]);
79
+ });
80
+
81
+ test('an invalid declaration fails before any request', async () => {
82
+ const fake = fakeAccess();
83
+ const failure = await run(fake, (p) =>
84
+ Effect.flip(reconcile(p, publicClient({ teamDomain: 'https://x.test' }))),
85
+ );
86
+ expect(String(failure)).toContain('teamDomain');
87
+ expect(fake.seen).toEqual([]);
88
+ });
89
+ });
90
+
91
+ describe('reconcile against a live app', () => {
92
+ test('makes zero writes when nothing drifted', async () => {
93
+ const fake = fakeAccess();
94
+ const [first, second] = await run(fake, (p) =>
95
+ Effect.gen(function* () {
96
+ const created = yield* reconcile(p, publicClient());
97
+ return [created, yield* reconcile(p, publicClient(), created)] as const;
98
+ }),
99
+ );
100
+ expect(second).toEqual(first);
101
+ expect(writes(fake)).toEqual(['POST']);
102
+ });
103
+
104
+ test('a changed redirect URI is one PUT, and the client id survives', async () => {
105
+ const fake = fakeAccess();
106
+ const base = publicClient();
107
+ const changed = {
108
+ ...base,
109
+ saasApp: { ...base.saasApp, redirectUris: ['https://headlamp.example.test/new'] },
110
+ };
111
+ const [first, second] = await run(fake, (p) =>
112
+ Effect.gen(function* () {
113
+ const created = yield* reconcile(p, base);
114
+ return [created, yield* reconcile(p, changed, created)] as const;
115
+ }),
116
+ );
117
+ expect(writes(fake)).toEqual(['POST', 'PUT']);
118
+ expect(second.redirectUris).toEqual(['https://headlamp.example.test/new']);
119
+ expect(second.clientId).toBe(first.clientId);
120
+ expect(second.applicationId).toBe(first.applicationId);
121
+ expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
122
+ });
123
+
124
+ test('a reordered policy list is one PUT that carries the new order', async () => {
125
+ const fake = fakeAccess();
126
+ const base = publicClient({ policies: ['policy-a', 'policy-b'] });
127
+ const swapped = { ...base, policies: ['policy-b', 'policy-a'] };
128
+ const second = await run(fake, (p) =>
129
+ Effect.gen(function* () {
130
+ const created = yield* reconcile(p, base);
131
+ return yield* reconcile(p, swapped, created);
132
+ }),
133
+ );
134
+ expect(second.applicationId).toBeDefined();
135
+ expect(writes(fake)).toEqual(['POST', 'PUT']);
136
+ expect(fake.seen.filter((s) => s.method === 'PUT')[0]?.body).toMatchObject({
137
+ policies: ['policy-b', 'policy-a'],
138
+ });
139
+ // The fake holds the new order, so the next read sees it in sync.
140
+ expect(fake.apps.get(second.applicationId)?.policies).toEqual(['policy-b', 'policy-a']);
141
+ });
142
+
143
+ test("a declared team that is not the app's team is refused before any write", async () => {
144
+ const fake = fakeAccess({ team: 'real.cloudflareaccess.com' });
145
+ const live = fake.seed({ name: 'Headlamp' });
146
+ const failure = await run(fake, (p) =>
147
+ Effect.flip(
148
+ reconcile(
149
+ p,
150
+ publicClient({ applicationId: live.id, teamDomain: 'other.cloudflareaccess.com' }),
151
+ ),
152
+ ),
153
+ );
154
+ expect(String(failure)).toContain('real.cloudflareaccess.com');
155
+ expect(writes(fake)).toEqual([]);
156
+ });
157
+
158
+ test('an owned row whose app was deleted out of band never adopts a same-named app', async () => {
159
+ const fake = fakeAccess();
160
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
161
+ fake.apps.delete(made.applicationId);
162
+ const impostor = fake.seed({
163
+ name: 'Headlamp',
164
+ policies: ['policy-admin'],
165
+ saas_app: LIVE_OIDC,
166
+ });
167
+ const before = fake.seen.length;
168
+ const out = await run(fake, (p) => reconcile(p, publicClient(), made));
169
+ // A fresh app is created; the same-named one is neither adopted nor written.
170
+ expect(out.applicationId).not.toBe(impostor.id);
171
+ expect(out.applicationId).not.toBe(made.applicationId);
172
+ expect(
173
+ fake.seen
174
+ .slice(before)
175
+ .filter((r) => r.method !== 'GET')
176
+ .map((r) => r.method),
177
+ ).toEqual(['POST']);
178
+ expect(fake.apps.get(impostor.id)?.saas_app?.['client_id']).toBe(
179
+ impostor.saas_app?.['client_id'],
180
+ );
181
+ });
182
+
183
+ test('an id that is not a saas app is refused, never rewritten', async () => {
184
+ const fake = fakeAccess();
185
+ const selfHosted = fake.seed({ name: 'Headlamp', type: 'self_hosted' });
186
+ const failure = await run(fake, (p) =>
187
+ Effect.flip(reconcile(p, publicClient({ applicationId: selfHosted.id }))),
188
+ );
189
+ expect(String(failure)).toContain('not "saas"');
190
+ expect(writes(fake)).toEqual([]);
191
+ });
192
+
193
+ test('two saas apps with the declared name are refused, not guessed between', async () => {
194
+ const fake = fakeAccess();
195
+ fake.seed({ name: 'Headlamp' });
196
+ fake.seed({ name: 'Headlamp' });
197
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
198
+ expect(String(failure)).toContain('matches 2 live apps');
199
+ expect(writes(fake)).toEqual([]);
200
+ });
201
+ });