Repository navigation
feat(server): read Bitbucket and GitCafe credentials from a secret provider #27746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
juliusmarminge
merged 10 commits into
pingdotgg:main
from
yordis:yordis/feat-secret-refs-provider-fields
Oct 30, 2026
+655
−119
Merged
Changes from 9 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
b0da0a3
feat(server): read Bitbucket and GitCafe credentials from a secret pr…
yordis 768311b
refactor(web): compare source control fields structurally
yordis 5b4af87
fix(server): keep GitHub settings reads from waiting on other hosts' …
yordis 8ca44fb
test(server): drop trivial secret reference cases and cover host-scop…
yordis 868b87f
fix(server): let the Settings refresh retry source control host secre…
yordis c4f5992
test(server): cover source control credentials whose secret cannot be…
yordis 72a1e3f
fix(server): a Settings refresh clears every cached secret when setti…
yordis 6bc1229
fix(secrets): a secret already read no longer waits behind another pr…
yordis bf47f29
test(web): office preview test no longer races on a shared response body
yordis e4b7869
refactor(settings): typing a field SettingsSecretValue is the whole s…
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { | ||
| isSecretReference, | ||
| type ServerSettings, | ||
| type SettingsSecretValue, | ||
| } from "@t3tools/contracts"; | ||
| import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; | ||
| import * as Effect from "effect/Effect"; | ||
|
|
||
| import { collectSourceControlHostSecretReferences } from "../sourceControl/sourceControlHostSecrets.ts"; | ||
|
|
||
| /** An unreadable secret comes back as `""`, so a credential that is missing behaves as unset. */ | ||
| export const resolveSettingsSecret = Effect.fn("SettingsSecrets.resolve")(function* ( | ||
| resolver: SecretResolver.SecretResolver["Service"], | ||
| value: SettingsSecretValue, | ||
| ) { | ||
| if (!isSecretReference(value)) return value; | ||
| const [secret] = yield* resolver.read([value]); | ||
| return secret ?? ""; | ||
| }); | ||
|
|
||
| /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ | ||
| export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* <E>( | ||
| resolver: SecretResolver.SecretResolver["Service"], | ||
| settings: Effect.Effect<Pick<ServerSettings, "sourceControlHosts">, E>, | ||
| ) { | ||
| const references = yield* settings.pipe( | ||
| Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), | ||
| Effect.orElseSucceed(() => undefined), | ||
| ); | ||
| yield* resolver.invalidate(references); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
apps/server/src/sourceControl/sourceControlHostSecrets.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { assert, it } from "@effect/vitest"; | ||
| import { SecretProviderId } from "@t3tools/contracts"; | ||
| import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
|
|
||
| import { | ||
| invalidateSettingsSecrets, | ||
| resolveSettingsSecret, | ||
| } from "../secrets/settingsSecretValue.ts"; | ||
| import { | ||
| collectSourceControlHostSecretReferences, | ||
| resolveSourceControlHostSecrets, | ||
| } from "./sourceControlHostSecrets.ts"; | ||
|
|
||
| const source = (reference: string) => ({ | ||
| provider: SecretProviderId.make("homelab"), | ||
| reference, | ||
| }); | ||
|
|
||
| const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); | ||
|
|
||
| const layerResolver = Layer.succeed(SecretResolver.SecretResolver, { | ||
| read: (references) => | ||
| Effect.succeed(references.map((reference) => secretsByReference.get(reference.reference))), | ||
| invalidate: () => Effect.void, | ||
| }); | ||
|
|
||
| const fakeResolver = SecretResolver.SecretResolver.use((resolver) => Effect.succeed(resolver)); | ||
|
|
||
| const hosts = { | ||
| bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, | ||
| gitcafe: { token: source("missing#token") }, | ||
| github: { tokens: { "github.com": "ghp_literal" } }, | ||
| }; | ||
|
|
||
| const countingResolver = Effect.gen(function* () { | ||
| const resolver = yield* fakeResolver; | ||
| const reads: Array<string> = []; | ||
| const resolve = (value: Parameters<typeof resolveSettingsSecret>[1]) => | ||
| Effect.sync(() => { | ||
| if (typeof value !== "string") reads.push(value.reference); | ||
| }).pipe(Effect.andThen(resolveSettingsSecret(resolver, value))); | ||
| return { reads, resolve }; | ||
| }); | ||
|
|
||
| it.effect( | ||
| "replaces only the requested kind's credential references with the secrets they name", | ||
| () => | ||
| Effect.gen(function* () { | ||
| const { reads, resolve } = yield* countingResolver; | ||
| const resolved = yield* resolveSourceControlHostSecrets(hosts, "bitbucket", resolve); | ||
| assert.deepEqual(resolved, { | ||
| ...hosts, | ||
| bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, | ||
| }); | ||
| assert.deepEqual(reads, ["bitbucket#token"]); | ||
| }).pipe(Effect.provide(layerResolver)), | ||
| ); | ||
|
|
||
| it.effect("treats a reference the provider cannot read as an unset credential", () => | ||
| Effect.gen(function* () { | ||
| const { reads, resolve } = yield* countingResolver; | ||
| const resolved = yield* resolveSourceControlHostSecrets(hosts, "gitcafe", resolve); | ||
| assert.deepEqual(resolved.gitcafe, { token: "" }); | ||
| assert.deepEqual(reads, ["missing#token"]); | ||
| }).pipe(Effect.provide(layerResolver)), | ||
| ); | ||
|
|
||
| it.effect("leaves the resolver unused for a kind that holds no reference", () => | ||
| Effect.gen(function* () { | ||
| const { reads, resolve } = yield* countingResolver; | ||
| const resolved = yield* resolveSourceControlHostSecrets(hosts, "github", resolve); | ||
| assert.strictEqual(resolved, hosts); | ||
| assert.deepEqual(reads, []); | ||
| }).pipe(Effect.provide(layerResolver)), | ||
| ); | ||
|
|
||
| it("collects the references held in credential fields and skips literal values", () => { | ||
| assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ | ||
| source("missing#token"), | ||
| source("bitbucket#token"), | ||
| ]); | ||
| assert.deepEqual( | ||
| collectSourceControlHostSecretReferences({ | ||
| bitbucket: { accessToken: "literal" }, | ||
| github: { tokens: { "github.com": "ghp_literal" } }, | ||
| }), | ||
| [], | ||
| ); | ||
| }); | ||
|
|
||
| it.effect("reads a collected reference again once it is invalidated", () => | ||
| Effect.gen(function* () { | ||
| let current = "stale"; | ||
| const cache = new Map<string, string>(); | ||
| const resolver = SecretResolver.SecretResolver.of({ | ||
| read: (references) => | ||
| Effect.sync(() => | ||
| references.map((reference) => { | ||
| const hit = cache.get(reference.reference); | ||
| if (hit !== undefined) return hit; | ||
| cache.set(reference.reference, current); | ||
| return current; | ||
| }), | ||
| ), | ||
| invalidate: (references) => | ||
| Effect.sync(() => { | ||
| if (references === undefined) cache.clear(); | ||
| else for (const reference of references) cache.delete(reference.reference); | ||
| }), | ||
| }); | ||
| const read = () => | ||
| resolveSourceControlHostSecrets(hosts, "bitbucket", (reference) => | ||
| resolveSettingsSecret(resolver, reference), | ||
| ); | ||
| assert.deepEqual((yield* read()).bitbucket, { | ||
| email: "me@example.test", | ||
| accessToken: "stale", | ||
| }); | ||
| current = "rotated"; | ||
| yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); | ||
| assert.deepEqual((yield* read()).bitbucket, { | ||
| email: "me@example.test", | ||
| accessToken: "rotated", | ||
| }); | ||
| current = "rotated again"; | ||
| yield* invalidateSettingsSecrets(resolver, Effect.fail("settings unreadable")); | ||
| assert.deepEqual((yield* read()).bitbucket, { | ||
| email: "me@example.test", | ||
| accessToken: "rotated again", | ||
| }); | ||
| }), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.