@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,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
+ });
@@ -0,0 +1,107 @@
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 Context from 'effect/Context';
26
+ import * as Effect from 'effect/Effect';
27
+ import * as HttpClient from 'effect/http/HttpClient';
28
+ import type { Providers } from './providers.ts';
29
+ import { deleteSaasOidc, readSaasOidc, reconcileSaasOidc } from './saas-oidc-lifecycle.ts';
30
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
31
+
32
+ export interface SaasOidcApplication extends Resource<
33
+ 'HomeFlare.Access.SaasOidcApplication',
34
+ SaasOidcApplicationProps,
35
+ SaasOidcApplicationAttributes,
36
+ never,
37
+ Providers
38
+ > {}
39
+
40
+ export const SaasOidcApplication: ResourceClass<SaasOidcApplication> =
41
+ Resource<SaasOidcApplication>('HomeFlare.Access.SaasOidcApplication', {
42
+ defaultRemovalPolicy: 'retain',
43
+ });
44
+
45
+ const accountId = Effect.gen(function* () {
46
+ const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
47
+ return resolved.accountId;
48
+ });
49
+
50
+ /**
51
+ * ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, as MeshNodeProvider does
52
+ * (mesh-node.ts): correct whether or not the stack also merges `Cloudflare.providers()`.
53
+ */
54
+ export const SaasOidcApplicationProvider = () =>
55
+ Provider.effect(
56
+ SaasOidcApplication,
57
+ Effect.gen(function* () {
58
+ const services = Context.make(
59
+ Cloudflare.CloudflareEnvironment,
60
+ yield* Cloudflare.CloudflareEnvironment,
61
+ ).pipe(
62
+ Context.add(Credentials, yield* Credentials),
63
+ Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
64
+ );
65
+ // ★ Only the three captured services are provided; `createPhysicalName` still takes
66
+ // InstanceId, Stack and Stage from the engine at call time, as in every Alchemy provider.
67
+ const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
68
+ Effect.provideContext(effect, services);
69
+
70
+ return SaasOidcApplication.Provider.of({
71
+ /**
72
+ * ★ THE ISSUER AND EVERY ENDPOINT ARE STABLE. They depend only on the team domain and the
73
+ * client id, and `checkTeam` refuses any team other than the app's own host, so no update
74
+ * of this app can move them. Alchemy's plan (Plan.ts `withStables`, beta.81) keeps ONLY
75
+ * the listed attributes resolved for consumers of an updated resource; without them, a
76
+ * redirect-URI change would leave `app.issuer` unresolved and re-plan every consumer
77
+ * (the Talos KubeAuthenticationConfig, so a kube-apiserver restart on each node).
78
+ * ⚠️ `teamDomain` is NOT listed: state written by the openbao copy has no such attribute,
79
+ * so a consumer would read `undefined` on the first plan after the move.
80
+ */
81
+ stables: [
82
+ 'applicationId',
83
+ 'aud',
84
+ 'clientId',
85
+ 'accountId',
86
+ 'issuer',
87
+ 'authorizationEndpoint',
88
+ 'tokenEndpoint',
89
+ 'jwksEndpoint',
90
+ 'userinfoEndpoint',
91
+ 'configurationEndpoint',
92
+ ],
93
+ /**
94
+ * ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Access.Application` already lists
95
+ * every Access application in the account for `alchemy unsafe nuke` (its `list`, saas
96
+ * apps included); a second listing here would make nuke delete each app twice.
97
+ */
98
+ list: () => Effect.succeed([]),
99
+ nuke: { skip: true },
100
+ read: ({ id, olds, output }) =>
101
+ run(Effect.flatMap(accountId, (account) => readSaasOidc(account, id, olds, output))),
102
+ reconcile: ({ id, news, output }) =>
103
+ run(Effect.flatMap(accountId, (account) => reconcileSaasOidc(account, id, news, output))),
104
+ delete: ({ output }) => run(deleteSaasOidc(output)),
105
+ });
106
+ }),
107
+ );