@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,150 @@
1
+ /**
2
+ * read / reconcile / delete for `HomeFlare.Access.SaasOidcApplication`, as plain Effects over the
3
+ * calls in saas-oidc-api.ts. saas-oidc.ts only wires them into Alchemy.
4
+ */
5
+ import { Unowned } from 'alchemy/AdoptPolicy';
6
+ import { createPhysicalName } from 'alchemy/PhysicalName';
7
+ import * as Effect from 'effect/Effect';
8
+ import { createApp, deleteApp, findSaasByName, getApp, updateApp } from './saas-oidc-api.ts';
9
+ import { SaasOidcError } from './saas-oidc-error.ts';
10
+ import {
11
+ type SaasOidcApplicationAttributes,
12
+ type SaasOidcApplicationProps,
13
+ checkTeam,
14
+ needsSync,
15
+ toAttributes,
16
+ validateSaasOidc,
17
+ writeBody,
18
+ } from './saas-oidc-form.ts';
19
+ import { checkTeamBeforeCreate } from './saas-oidc-team.ts';
20
+ import type { ObservedApp } from './saas-oidc-wire.ts';
21
+
22
+ const resolveName = (id: string, name: string | undefined) =>
23
+ name !== undefined && name !== '' ? Effect.succeed(name) : createPhysicalName({ id });
24
+
25
+ /**
26
+ * The live app: by the known id when there is one, else by exact name. A saas app found by id that
27
+ * is NOT type `saas` is refused, never rewritten: `applicationId` pointing at a self-hosted app
28
+ * would otherwise have its type overwritten by the update.
29
+ */
30
+ const observe = (
31
+ accountId: string,
32
+ knownId: string | undefined,
33
+ name: string,
34
+ options: { readonly byIdOnly?: boolean } = {},
35
+ ) =>
36
+ Effect.gen(function* () {
37
+ const byId = knownId === undefined ? undefined : yield* getApp(accountId, knownId);
38
+ if (byId !== undefined) {
39
+ if (byId.type !== 'saas') {
40
+ return yield* Effect.fail(
41
+ new SaasOidcError({
42
+ message: `Access application ${knownId} is type "${byId.type ?? 'unknown'}", not "saas". Refusing to rewrite it as a SaaS OIDC app.`,
43
+ }),
44
+ );
45
+ }
46
+ return byId;
47
+ }
48
+ // ★ An OWNED row's app that is gone stays gone: another saas app with the same name is not it,
49
+ // and its applicationId and clientId (declared stables) must not change under the row.
50
+ if (options.byIdOnly === true) return undefined;
51
+ const foundId = yield* findSaasByName(accountId, name);
52
+ return foundId === undefined ? undefined : yield* getApp(accountId, foundId);
53
+ });
54
+
55
+ /**
56
+ * Owned: refresh by the stored id ONLY (gone is `undefined`, never a same-named app). Cold: find the app by `applicationId` or exact name and hand it
57
+ * back `Unowned`, so Alchemy refuses to take it over until the stack says `adopt(true)`.
58
+ * ★ `olds` MAY PREDATE `teamDomain` (state written by the openbao copy of this resource): the host
59
+ * Cloudflare reports for the app stands in, so the first plan after the move can still read.
60
+ */
61
+ export const readSaasOidc = (
62
+ accountId: string,
63
+ id: string,
64
+ olds: SaasOidcApplicationProps | undefined,
65
+ output: SaasOidcApplicationAttributes | undefined,
66
+ ) =>
67
+ Effect.gen(function* () {
68
+ const name = yield* resolveName(id, output?.name ?? olds?.name);
69
+ const owned = output?.applicationId;
70
+ const observed = yield* observe(accountId, owned ?? olds?.applicationId, name, {
71
+ byIdOnly: owned !== undefined,
72
+ });
73
+ if (observed === undefined) return undefined;
74
+ const attributes = toAttributes(
75
+ observed,
76
+ output?.accountId ?? accountId,
77
+ name,
78
+ output?.teamDomain ?? olds?.teamDomain,
79
+ );
80
+ if (attributes === undefined) return undefined;
81
+ return owned !== undefined ? attributes : Unowned(attributes);
82
+ });
83
+
84
+ const missing = (what: string) =>
85
+ Effect.fail(
86
+ new SaasOidcError({
87
+ message: `SaaS OIDC ${what} succeeded but the GET did not return the app.`,
88
+ }),
89
+ );
90
+
91
+ /** observe, ensure (create or sync), read back. A no-op when nothing drifted. */
92
+ export const reconcileSaasOidc = (
93
+ accountId: string,
94
+ id: string,
95
+ news: SaasOidcApplicationProps,
96
+ output: SaasOidcApplicationAttributes | undefined,
97
+ ) =>
98
+ Effect.gen(function* () {
99
+ const invalid = validateSaasOidc(news);
100
+ if (invalid !== undefined) return yield* Effect.fail(invalid);
101
+ const name = yield* resolveName(id, news.name);
102
+ // ★ A stored applicationId means the row owns THAT app: if it is gone, create (or refuse),
103
+ // never adopt whatever app now carries the name. Only a cold run may look up by name.
104
+ const ownedId = output?.applicationId;
105
+ let observed: ObservedApp | undefined = yield* observe(
106
+ accountId,
107
+ ownedId ?? news.applicationId,
108
+ name,
109
+ { byIdOnly: ownedId !== undefined },
110
+ );
111
+ // ⛔ BEFORE ANY WRITE TO AN EXISTING APP: a wrong team domain must not cost it an update.
112
+ // A FIRST CREATE has no app to compare with, so it is checked against the account's Zero
113
+ // Trust organization instead (`checkTeamBeforeCreate`, below). ⚠️ That read needs a token
114
+ // permission the app write does not (saas-oidc-team.ts): where it cannot be read, a wrong
115
+ // `teamDomain` still writes the app and then fails the post-write check, leaving a live app
116
+ // with no state row. docs/saas-oidc.md says what to do.
117
+ const wrongTeam =
118
+ observed === undefined ? undefined : checkTeam(news.teamDomain, observed, output?.teamDomain);
119
+ if (wrongTeam !== undefined) return yield* Effect.fail(wrongTeam);
120
+ const write = writeBody(news, name);
121
+ if (observed === undefined) {
122
+ const wrongOrganization = yield* checkTeamBeforeCreate(accountId, news.teamDomain);
123
+ if (wrongOrganization !== undefined) return yield* Effect.fail(wrongOrganization);
124
+ const createdId = yield* createApp(accountId, write);
125
+ observed = yield* getApp(accountId, createdId);
126
+ if (observed === undefined) return yield* missing('create');
127
+ } else if (needsSync(news, observed, name)) {
128
+ const appId = observed.id;
129
+ if (appId === undefined) {
130
+ return yield* Effect.fail(new SaasOidcError({ message: 'SaaS OIDC app has no id.' }));
131
+ }
132
+ yield* updateApp(accountId, appId, write);
133
+ observed = yield* getApp(accountId, appId);
134
+ if (observed === undefined) return yield* missing('update');
135
+ }
136
+ const teamAfterWrite = checkTeam(news.teamDomain, observed, output?.teamDomain);
137
+ if (teamAfterWrite !== undefined) return yield* Effect.fail(teamAfterWrite);
138
+ const attributes = toAttributes(observed, accountId, name, news.teamDomain);
139
+ if (attributes === undefined) {
140
+ return yield* Effect.fail(
141
+ new SaasOidcError({
142
+ message: 'Cloudflare returned a SaaS OIDC application without an id, aud or client id.',
143
+ }),
144
+ );
145
+ }
146
+ return attributes;
147
+ });
148
+
149
+ export const deleteSaasOidc = (output: SaasOidcApplicationAttributes) =>
150
+ deleteApp(output.accountId, output.applicationId);
@@ -0,0 +1,180 @@
1
+ /**
2
+ * A response the SDK cannot validate must not carry the client secret out of this package.
3
+ *
4
+ * ⛔ THE LEAK THIS PINS. Under STRICT response validation, `@distilled.cloud/cloudflare@1.0.0-rc.13`
5
+ * fails a 2xx response it cannot decode with `CloudflareParseError({ body, cause })`, `body` being
6
+ * the WHOLE parsed response (protocol.ts:452-457). For a confidential client a create response
7
+ * carries `client_secret`, so a raw SDK error that reached a log or a trace would print it.
8
+ * `fake-access.ts` plants `FAKE_CLIENT_SECRET` in a body the decoder rejects, for each call.
9
+ * ★ THE FIRST TEST IS THE PREMISE: the raw SDK error really does carry the secret under the strict
10
+ * switch `strict` provides, so a renamed key or a lenient default fails there, not silently below.
11
+ */
12
+ import { describe, expect, spyOn, test } from 'bun:test';
13
+ import { CloudflareParseError } from '@distilled.cloud/cloudflare/Errors';
14
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
15
+ import * as Cause from 'effect/Cause';
16
+ import * as Effect from 'effect/Effect';
17
+ import * as Exit from 'effect/Exit';
18
+ import { FAKE_CLIENT_SECRET, fakeAccess } from './fake-access.ts';
19
+ import { FAKE_ACCOUNT, fakeClientLayer } from './fake-mesh.ts';
20
+ import { deleteSaasOidc } from './saas-oidc-lifecycle.ts';
21
+ import { sdkWrites } from './saas-oidc-api.ts';
22
+ import { LIVE_OIDC, publicClient, reconcile, run, strict } from './saas-oidc-harness.ts';
23
+
24
+ /** Run `go` with the console watched; the second member is everything it logged. */
25
+ const watched = async <T>(go: () => Promise<T>): Promise<readonly [T, string]> => {
26
+ const spies = [spyOn(console, 'log'), spyOn(console, 'error'), spyOn(console, 'warn')];
27
+ try {
28
+ const result = await go();
29
+ return [result, JSON.stringify(spies.map((spy) => spy.mock.calls))];
30
+ } finally {
31
+ for (const spy of spies) spy.mockRestore();
32
+ }
33
+ };
34
+
35
+ /** Everything a caller or a log could render from a failure: nothing may hold the secret. */
36
+ const surfaces = (exit: Exit.Exit<unknown, unknown>, logged: string): string[] => {
37
+ if (!Exit.isFailure(exit)) throw new Error('expected a failure');
38
+ const error = Cause.squash(exit.cause);
39
+ const fields = (error instanceof Error ? Object.getOwnPropertyNames(error) : []).map(
40
+ (key) => (error as unknown as Record<string, unknown>)[key],
41
+ );
42
+ return [
43
+ String(error),
44
+ JSON.stringify(error),
45
+ JSON.stringify(fields),
46
+ error instanceof Error ? error.message : '',
47
+ Cause.pretty(exit.cause),
48
+ logged,
49
+ ];
50
+ };
51
+
52
+ const failureOf = (exit: Exit.Exit<unknown, unknown>): { _tag: string; message: string } => {
53
+ if (!Exit.isFailure(exit)) throw new Error('expected a failure');
54
+ return Cause.squash(exit.cause) as { _tag: string; message: string };
55
+ };
56
+
57
+ type Outcome = Promise<Exit.Exit<unknown, unknown>>;
58
+
59
+ /**
60
+ * The error rc.13 builds (protocol.ts:452-457) for a create that answered with a secret: the whole
61
+ * envelope as `body`, a SchemaError-like `cause` quoting the offending value.
62
+ */
63
+ const rawParseError = () =>
64
+ new CloudflareParseError({
65
+ body: { success: true, result: { id: 'app', saas_app: { client_secret: FAKE_CLIENT_SECRET } } },
66
+ cause: `Expected string, actual ${FAKE_CLIENT_SECRET}`,
67
+ });
68
+
69
+ /** Stand in for one SDK write that fails with the raw parse error, then put the real ones back. */
70
+ const withRawWrite = async (which: 'create' | 'update', go: () => Outcome): Outcome => {
71
+ const real = { ...sdkWrites };
72
+ Object.assign(sdkWrites, { [which]: () => Effect.fail(rawParseError()) });
73
+ try {
74
+ return await go();
75
+ } finally {
76
+ Object.assign(sdkWrites, real);
77
+ }
78
+ };
79
+
80
+ /**
81
+ * One way to make each call fail with CloudflareParseError, and what the sanitized error names.
82
+ * ⚠️ create and update go through `sdkWrites` because rc.13 cannot be made to fail them (their
83
+ * response decodes against `S.Unknown`, see saas-oidc-error.ts); the other three are the REAL SDK
84
+ * under strict validation, fed a body its typed schema rejects.
85
+ */
86
+ const scenarios: ReadonlyArray<readonly [string, string, () => Outcome]> = [
87
+ [
88
+ 'create',
89
+ 'application create',
90
+ () =>
91
+ withRawWrite('create', () =>
92
+ run(fakeAccess(), (p) => Effect.exit(reconcile(p, publicClient()))),
93
+ ),
94
+ ],
95
+ [
96
+ 'update',
97
+ 'application update',
98
+ () => {
99
+ const fake = fakeAccess();
100
+ const live = fake.seed({ name: 'Headlamp', policies: ['policy-old'], saas_app: LIVE_OIDC });
101
+ return withRawWrite('update', () =>
102
+ run(fake, (p) => Effect.exit(reconcile(p, publicClient({ applicationId: live.id })))),
103
+ );
104
+ },
105
+ ],
106
+ [
107
+ 'list',
108
+ 'application list',
109
+ () =>
110
+ run(fakeAccess({ malformed: ['list'] }), (p) =>
111
+ Effect.exit(strict(reconcile(p, publicClient()))),
112
+ ),
113
+ ],
114
+ [
115
+ 'org',
116
+ 'organization read',
117
+ () =>
118
+ run(fakeAccess({ malformed: ['org'] }), (p) =>
119
+ Effect.exit(strict(reconcile(p, publicClient()))),
120
+ ),
121
+ ],
122
+ [
123
+ 'delete',
124
+ 'application delete',
125
+ async () => {
126
+ const fake = fakeAccess({ malformed: ['delete'] });
127
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
128
+ return Effect.runPromiseExit(
129
+ strict(deleteSaasOidc(made)).pipe(Effect.provide(fakeClientLayer(fake))),
130
+ );
131
+ },
132
+ ],
133
+ ];
134
+
135
+ describe('a response the SDK cannot validate', () => {
136
+ test('premise: the real SDK raises CloudflareParseError carrying the whole response', async () => {
137
+ const fake = fakeAccess({ malformed: ['delete'] });
138
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
139
+ const raw = await Effect.runPromise(
140
+ strict(
141
+ zeroTrust.deleteAccessApplicationForAccount({
142
+ accountId: FAKE_ACCOUNT,
143
+ appId: made.applicationId,
144
+ }),
145
+ ).pipe(Effect.flip, Effect.provide(fakeClientLayer(fake))),
146
+ );
147
+ expect(raw).toMatchObject({
148
+ _tag: 'CloudflareParseError',
149
+ body: { result: { client_secret: FAKE_CLIENT_SECRET } },
150
+ });
151
+ });
152
+
153
+ test.each(scenarios)(
154
+ '%s: only the operation is named, never a body or a field',
155
+ async (_op, name, go) => {
156
+ const [exit, logged] = await watched(go);
157
+ const error = failureOf(exit);
158
+ expect(error._tag).toBe('SaasOidcError');
159
+ expect(error.message).toContain(name);
160
+ expect(error.message).toContain('withheld');
161
+ for (const surface of surfaces(exit, logged)) {
162
+ expect(surface).not.toContain(FAKE_CLIENT_SECRET);
163
+ expect(surface).not.toContain('client_secret');
164
+ }
165
+ },
166
+ );
167
+
168
+ test('the seam leaves the real SDK in place afterwards', () => {
169
+ expect(sdkWrites.create).toBe(zeroTrust.createAccessApplicationForAccount);
170
+ expect(sdkWrites.update).toBe(zeroTrust.updateAccessApplicationForAccount);
171
+ });
172
+
173
+ test('the raw read (not an SDK call) fails on a non-JSON body without echoing it', async () => {
174
+ const [exit, logged] = await watched(() =>
175
+ run(fakeAccess({ malformed: ['get'] }), (p) => Effect.exit(reconcile(p, publicClient()))),
176
+ );
177
+ expect(failureOf(exit).message).toContain('was not JSON');
178
+ for (const surface of surfaces(exit, logged)) expect(surface).not.toContain(FAKE_CLIENT_SECRET);
179
+ });
180
+ });
@@ -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
+ });