@milkenjoyer/secrets-client 0.0.0-stage → 0.2.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.
- package/README.md +73 -2
- package/dist/connect.d.ts +34 -0
- package/dist/connect.js +53 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +102 -0
- package/dist/keychain.d.ts +6 -0
- package/dist/keychain.js +58 -0
- package/dist/refused.d.ts +16 -0
- package/dist/refused.js +35 -0
- package/package.json +40 -4
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @birbprophet/secrets-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The one 1Password Connect client for siglata, vibebattler and optionhouse. It replaces siglata's `packages/edge/scripts/connect-secrets.ts`, vibebattler's `scripts/connect-secret.ts` and optionhouse's `scripts/secrets-vault.ts`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Secrets, live } from "@birbprophet/secrets-client";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
|
|
9
|
+
const program = Effect.gen(function* () {
|
|
10
|
+
const secrets = yield* Secrets;
|
|
11
|
+
const apiKey = yield* secrets.read({ vault: "shared-infra", item: "boat", field: "credential" });
|
|
12
|
+
const deploy = yield* secrets.readItem({ vault: "vibebattler-infra", item: "deploy" });
|
|
13
|
+
return { apiKey, deploy };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
program.pipe(Effect.provide(live("vibebattler")));
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Secrets, live } from "@birbprophet/secrets-client";
|
|
21
|
+
import * as Effect from "effect/Effect";
|
|
22
|
+
import type * as Redacted from "effect/Redacted";
|
|
23
|
+
|
|
24
|
+
export const rotate = (apiKey: Redacted.Redacted) =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const secrets = yield* Secrets;
|
|
27
|
+
return yield* secrets.write({
|
|
28
|
+
vault: "vibebattler-infra",
|
|
29
|
+
item: "deploy",
|
|
30
|
+
fields: { API_KEY: apiKey },
|
|
31
|
+
});
|
|
32
|
+
}).pipe(Effect.provide(live("vibebattler", { token: "connect-write" })));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `read({ vault, item, field })` returns one field as a `Redacted`. A missing or empty field is refused.
|
|
36
|
+
- `readItem({ vault, item })` returns every field with a label and a value, keyed by label, each a `Redacted` labelled `vault/item/field`.
|
|
37
|
+
- `write({ vault, item, fields })` takes each value as a `Redacted`. If no item in the vault has that title, it creates one and answers `"created"`. The new item is an API Credential, and each field is concealed. If one item has that title, it replaces the value of each field it names, adds each field the item lacks, keeps every other field, and answers `"updated"`.
|
|
38
|
+
- A write needs a token that may write, such as `connect-write`. Connect refuses a read token's write with 403.
|
|
39
|
+
- A vault name, item title or field label that two entries share is refused, so the client never guesses. A write checks every label it names before it sends anything.
|
|
40
|
+
- One layer reads the token once, lists `/v1/vaults` once and each vault's items once, then reuses those lists. The door allows 60 calls a minute per IP. A failed read or list is not kept, so a later call tries again. A write drops its vault's item list, so the next call lists that vault again and sees the write.
|
|
41
|
+
|
|
42
|
+
## Connect
|
|
43
|
+
|
|
44
|
+
The client calls Connect through `@distilled.cloud/onepassword`'s Connect operations, on Effect's `HttpClient`. It sends only routes the door forwards, with no trailing slash and no query:
|
|
45
|
+
|
|
46
|
+
- `GET /v1/vaults`, `GET /v1/vaults/<id>/items` and `GET /v1/vaults/<id>/items/<id>` to read.
|
|
47
|
+
- `POST /v1/vaults/<id>/items` to create an item.
|
|
48
|
+
- `PATCH /v1/vaults/<id>/items/<id>` to replace or add fields.
|
|
49
|
+
|
|
50
|
+
It never sends PUT. A PUT sends the whole item back, and it would drop any property that distilled's schema does not model. A PATCH changes only the fields it names. Answers are checked against the Connect schema, calls are not retried, and tracing is off inside each call, because distilled's spans hold the answer and the request.
|
|
51
|
+
|
|
52
|
+
## The Keychain token
|
|
53
|
+
|
|
54
|
+
`live(repo)` reads the token from the macOS login Keychain, as service `<repo>-connect-read` and account `connect`. `live(repo, { token: "connect-write" })` reads Keychain service `connect-write` instead, which holds the owner's write token. `live(repo, { url })` calls another Connect URL. `bun:ffi` calls `SecKeychainFindGenericPassword` in Security.framework, so no process starts and the token never sits in argv, env, a pipe or a file. The bytes go straight into a `Redacted`, then Security's buffer is zeroed and freed.
|
|
55
|
+
|
|
56
|
+
- The read needs bun on macOS. Elsewhere it is refused with status -4.
|
|
57
|
+
- bun isn't on the item's access list, so the first read asks whether bun may use the token. Answer Always Allow, once per token. macOS asks again after bun changes.
|
|
58
|
+
- Status -25300 means the token is missing, and -128 means the prompt was denied.
|
|
59
|
+
|
|
60
|
+
## Refusals
|
|
61
|
+
|
|
62
|
+
Every failure is a `ConnectRefused` holding only a `route` and a `status`, and its message is built from those two. The original error is dropped. A route is a Connect call (`GET /v1/vaults`, `POST /v1/vaults/<id>/items`), a lookup (`vault shared-infra`, `item shared-infra/boat`, `field shared-infra/boat/credential`) or the Keychain (`keychain vibebattler-connect-read`). Status 0 means Connect gave no answer, from a network failure or after 60 seconds, 200 means its answer had the wrong shape, 403 means the door refused the call or Connect refused it for the token's access, 404 means a name was not found or a field is empty, and 409 means a name matched more than one entry. `test/redaction.test.ts` checks over generated values and tokens, for reads and for writes, that neither ever appears in a refusal, `String`, JSON, `inspect`, `Cause.pretty`, a log line or a span.
|
|
63
|
+
|
|
64
|
+
## Readback
|
|
65
|
+
|
|
66
|
+
`bun scripts/readback.ts <repo>` prints the vault names and item titles the repo's token can read, and how many fields `readItem` returns for the first item in each vault. It prints no value.
|
|
67
|
+
|
|
68
|
+
## Writeback
|
|
69
|
+
|
|
70
|
+
`bun scripts/writeback.ts <vault> <read-repo>` checks writes through the live door. With the `connect-write` token, it creates an item titled `secrets#6 writeback <time>` with a random `probe` field, then adds a `probe-2` field. It prints `created`, `updated` and the item's field labels. Then it tries a write to that item with `<read-repo>`'s read token and prints the refusal's route and status. If that write is not refused, it says so loudly. It prints the item's vault and title last, so you can delete the item by hand. It prints no value and no token.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
The package is on GitHub Packages, published by `.github/workflows/publish-client.yml` when a version not yet published lands on main. A product repo reads it with `AGENT_KIT_PACKAGE_READ_TOKEN`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { HttpClient } from "effect/http";
|
|
3
|
+
import * as Redacted from "effect/Redacted";
|
|
4
|
+
import { type ConnectRefused } from "./refused.ts";
|
|
5
|
+
export type Listed = {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
};
|
|
9
|
+
export type Field = {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly value?: string;
|
|
13
|
+
};
|
|
14
|
+
export type NewField = {
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly value: Redacted.Redacted;
|
|
17
|
+
};
|
|
18
|
+
export type FieldPatch = {
|
|
19
|
+
readonly op: "add";
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly value: Redacted.Redacted;
|
|
22
|
+
} | {
|
|
23
|
+
readonly op: "replace";
|
|
24
|
+
readonly fieldId: string;
|
|
25
|
+
readonly value: Redacted.Redacted;
|
|
26
|
+
};
|
|
27
|
+
export type ConnectRoutes = {
|
|
28
|
+
readonly vaults: Effect.Effect<ReadonlyArray<Listed>, ConnectRefused>;
|
|
29
|
+
readonly items: (vaultId: string) => Effect.Effect<ReadonlyArray<Listed>, ConnectRefused>;
|
|
30
|
+
readonly fields: (vaultId: string, itemId: string) => Effect.Effect<ReadonlyArray<Field>, ConnectRefused>;
|
|
31
|
+
readonly create: (vaultId: string, title: string, fields: ReadonlyArray<NewField>) => Effect.Effect<void, ConnectRefused>;
|
|
32
|
+
readonly patch: (vaultId: string, itemId: string, patches: ReadonlyArray<FieldPatch>) => Effect.Effect<void, ConnectRefused>;
|
|
33
|
+
};
|
|
34
|
+
export declare const connectRoutes: (client: HttpClient.HttpClient, url: string, token: Effect.Effect<Redacted.Redacted, ConnectRefused>) => ConnectRoutes;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as ResponseValidation from "@distilled.cloud/core/response-validation";
|
|
2
|
+
import * as Connect from "@distilled.cloud/onepassword/connect";
|
|
3
|
+
import { ConnectCredentials } from "@distilled.cloud/onepassword/Credentials";
|
|
4
|
+
import * as Retry from "@distilled.cloud/onepassword/Retry";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import { HttpClient } from "effect/http";
|
|
7
|
+
import * as Redacted from "effect/Redacted";
|
|
8
|
+
import * as Ref from "effect/Ref";
|
|
9
|
+
import { NO_ANSWER, refused } from "./refused.js";
|
|
10
|
+
const ANSWER_TIMEOUT = "60 seconds";
|
|
11
|
+
const ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
12
|
+
const newFieldId = () => Array.from(crypto.getRandomValues(new Uint8Array(26)), (byte) => ID_CHARACTERS[byte % ID_CHARACTERS.length]).join("");
|
|
13
|
+
const concealed = ({ label, value }) => ({
|
|
14
|
+
id: newFieldId(),
|
|
15
|
+
type: "CONCEALED",
|
|
16
|
+
label,
|
|
17
|
+
value: Redacted.value(value),
|
|
18
|
+
});
|
|
19
|
+
const pointerToken = (token) => token.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
20
|
+
const patchItem = (patch) => patch.op === "add"
|
|
21
|
+
? { op: "add", path: "/fields", value: concealed(patch) }
|
|
22
|
+
: {
|
|
23
|
+
op: "replace",
|
|
24
|
+
path: `/fields/${pointerToken(patch.fieldId)}/value`,
|
|
25
|
+
value: Redacted.value(patch.value),
|
|
26
|
+
};
|
|
27
|
+
const listed = (id, name) => id === undefined || name === undefined ? [] : [{ id, name }];
|
|
28
|
+
export const connectRoutes = (client, url, token) => {
|
|
29
|
+
const apiBaseUrl = `${url.replace(/\/+$/u, "")}/v1`;
|
|
30
|
+
const call = (route, operation, picked) => Effect.gen(function* callRoute() {
|
|
31
|
+
const credentials = { token: yield* token, apiBaseUrl };
|
|
32
|
+
const status = yield* Ref.make(NO_ANSWER);
|
|
33
|
+
const refuse = Effect.flatMap(Ref.get(status), (answered) => Effect.fail(refused(route, answered)));
|
|
34
|
+
return yield* Effect.suspend(operation).pipe(Effect.map(picked), Effect.provide(ResponseValidation.additionalProperties), Retry.none, Effect.provideService(ConnectCredentials, Effect.succeed(credentials)), Effect.provideService(HttpClient.HttpClient, HttpClient.tap(client, (response) => Ref.set(status, response.status))), Effect.withTracerEnabled(false), Effect.timeout(ANSWER_TIMEOUT), Effect.catch(() => refuse), Effect.catchDefect(() => refuse));
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
vaults: call("GET /v1/vaults", () => Connect.getVaults({}), (vaults) => vaults.flatMap(({ id, name }) => listed(id, name))),
|
|
38
|
+
items: (vaultId) => call(`GET /v1/vaults/${vaultId}/items`, () => Connect.getVaultItems({ vaultUuid: vaultId }), (items) => items.flatMap(({ id, title }) => listed(id, title))),
|
|
39
|
+
fields: (vaultId, itemId) => call(`GET /v1/vaults/${vaultId}/items/${itemId}`, () => Connect.getVaultItemById({ vaultUuid: vaultId, itemUuid: itemId }), ({ fields = [] }) => fields.flatMap(({ id, label, value }) => label === undefined ? [] : [{ id, label, value }])),
|
|
40
|
+
create: (vaultId, title, fields) => call(`POST /v1/vaults/${vaultId}/items`, () => Connect.createVaultItem({
|
|
41
|
+
vaultUuid: vaultId,
|
|
42
|
+
vault: { id: vaultId },
|
|
43
|
+
title,
|
|
44
|
+
category: "API_CREDENTIAL",
|
|
45
|
+
fields: fields.map(concealed),
|
|
46
|
+
}), () => undefined),
|
|
47
|
+
patch: (vaultId, itemId, patches) => call(`PATCH /v1/vaults/${vaultId}/items/${itemId}`, () => Connect.updateVaultItemByItemUuid({
|
|
48
|
+
vaultUuid: vaultId,
|
|
49
|
+
itemUuid: itemId,
|
|
50
|
+
body: patches.map(patchItem),
|
|
51
|
+
}), () => undefined),
|
|
52
|
+
};
|
|
53
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as Context from "effect/Context";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import { HttpClient } from "effect/http";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import * as Redacted from "effect/Redacted";
|
|
6
|
+
import { type ConnectRefused } from "./refused.ts";
|
|
7
|
+
export { ConnectRefused, KEYCHAIN_DENIED, KEYCHAIN_MISSING, NEEDS_BUN, NO_ANSWER, } from "./refused.ts";
|
|
8
|
+
export { keychainService, readKeychainToken } from "./keychain.ts";
|
|
9
|
+
export declare const CONNECT_URL = "https://secrets-connect-prod.benjamin-tang-hz.workers.dev";
|
|
10
|
+
export type ItemName = {
|
|
11
|
+
readonly vault: string;
|
|
12
|
+
readonly item: string;
|
|
13
|
+
};
|
|
14
|
+
export type FieldName = ItemName & {
|
|
15
|
+
readonly field: string;
|
|
16
|
+
};
|
|
17
|
+
export type ItemWrite = ItemName & {
|
|
18
|
+
readonly fields: Readonly<Record<string, Redacted.Redacted>>;
|
|
19
|
+
};
|
|
20
|
+
export type Written = "created" | "updated";
|
|
21
|
+
export type SecretsClient = {
|
|
22
|
+
readonly read: (wanted: FieldName) => Effect.Effect<Redacted.Redacted, ConnectRefused>;
|
|
23
|
+
readonly readItem: (wanted: ItemName) => Effect.Effect<Readonly<Record<string, Redacted.Redacted>>, ConnectRefused>;
|
|
24
|
+
readonly write: (wanted: ItemWrite) => Effect.Effect<Written, ConnectRefused>;
|
|
25
|
+
};
|
|
26
|
+
declare const ConnectToken_base: Context.ServiceClass<ConnectToken, "@birbprophet/secrets-client/ConnectToken", Effect.Effect<Redacted.Redacted<string>, ConnectRefused, never>>;
|
|
27
|
+
export declare class ConnectToken extends ConnectToken_base {
|
|
28
|
+
}
|
|
29
|
+
declare const Secrets_base: Context.ServiceClass<Secrets, "@birbprophet/secrets-client/Secrets", SecretsClient>;
|
|
30
|
+
export declare class Secrets extends Secrets_base {
|
|
31
|
+
}
|
|
32
|
+
export declare const make: (url?: string) => Effect.Effect<SecretsClient, never, HttpClient.HttpClient | ConnectToken>;
|
|
33
|
+
export declare const layer: (url?: string) => Layer.Layer<Secrets, never, HttpClient.HttpClient | ConnectToken>;
|
|
34
|
+
export declare const keychainToken: (service: string) => Layer.Layer<ConnectToken>;
|
|
35
|
+
export declare const live: (repo: string, { token, url, }?: {
|
|
36
|
+
readonly token?: string;
|
|
37
|
+
readonly url?: string;
|
|
38
|
+
}) => Layer.Layer<Secrets>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as Cache from "effect/Cache";
|
|
2
|
+
import * as Context from "effect/Context";
|
|
3
|
+
import * as Duration from "effect/Duration";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Exit from "effect/Exit";
|
|
6
|
+
import { FetchHttpClient, HttpClient } from "effect/http";
|
|
7
|
+
import * as Layer from "effect/Layer";
|
|
8
|
+
import * as Redacted from "effect/Redacted";
|
|
9
|
+
import { connectRoutes, } from "./connect.js";
|
|
10
|
+
import { keychainService, readKeychainToken } from "./keychain.js";
|
|
11
|
+
import { AMBIGUOUS, NOT_FOUND, refused, } from "./refused.js";
|
|
12
|
+
export { ConnectRefused, KEYCHAIN_DENIED, KEYCHAIN_MISSING, NEEDS_BUN, NO_ANSWER, } from "./refused.js";
|
|
13
|
+
export { keychainService, readKeychainToken } from "./keychain.js";
|
|
14
|
+
export const CONNECT_URL = "https://secrets-connect-prod.benjamin-tang-hz.workers.dev";
|
|
15
|
+
const VAULT_CAPACITY = 64;
|
|
16
|
+
export class ConnectToken extends Context.Service()("@birbprophet/secrets-client/ConnectToken") {
|
|
17
|
+
}
|
|
18
|
+
export class Secrets extends Context.Service()("@birbprophet/secrets-client/Secrets") {
|
|
19
|
+
}
|
|
20
|
+
const only = (matching, route) => {
|
|
21
|
+
const [first, ...others] = matching;
|
|
22
|
+
if (first === undefined) {
|
|
23
|
+
return Effect.fail(refused(route, NOT_FOUND));
|
|
24
|
+
}
|
|
25
|
+
return others.length === 0
|
|
26
|
+
? Effect.succeed(first)
|
|
27
|
+
: Effect.fail(refused(route, AMBIGUOUS));
|
|
28
|
+
};
|
|
29
|
+
const onlyId = (listed, name, route) => Effect.map(only(listed.filter((entry) => entry.name === name), route), ({ id }) => id);
|
|
30
|
+
const valued = (fields) => fields.flatMap(({ label, value }) => value === undefined ? [] : [{ label, value }]);
|
|
31
|
+
const fieldRoute = (wanted) => `field ${wanted.vault}/${wanted.item}/${wanted.field}`;
|
|
32
|
+
const fieldPatches = (wanted, fields) => Effect.forEach(Object.entries(wanted.fields), ([label, value]) => {
|
|
33
|
+
const [first, ...others] = fields.filter((field) => field.label === label);
|
|
34
|
+
if (first === undefined) {
|
|
35
|
+
return Effect.succeed({ op: "add", label, value });
|
|
36
|
+
}
|
|
37
|
+
return others.length === 0
|
|
38
|
+
? Effect.succeed({ op: "replace", fieldId: first.id, value })
|
|
39
|
+
: Effect.fail(refused(fieldRoute({ ...wanted, field: label }), AMBIGUOUS));
|
|
40
|
+
});
|
|
41
|
+
const keptOnSuccess = (exit) => Exit.isSuccess(exit) ? Duration.infinity : Duration.zero;
|
|
42
|
+
export const make = (url = CONNECT_URL) => Effect.gen(function* makeSecrets() {
|
|
43
|
+
const client = yield* HttpClient.HttpClient;
|
|
44
|
+
const token = yield* Effect.cachedWithTTL(yield* ConnectToken, keptOnSuccess);
|
|
45
|
+
const routes = connectRoutes(client, url, token);
|
|
46
|
+
const vaults = yield* Effect.cachedWithTTL(routes.vaults, keptOnSuccess);
|
|
47
|
+
const items = yield* Cache.makeWith(routes.items, {
|
|
48
|
+
capacity: VAULT_CAPACITY,
|
|
49
|
+
timeToLive: keptOnSuccess,
|
|
50
|
+
});
|
|
51
|
+
const vaultId = (wanted) => Effect.flatMap(vaults, (listed) => onlyId(listed, wanted.vault, `vault ${wanted.vault}`));
|
|
52
|
+
const itemRoute = (wanted) => `item ${wanted.vault}/${wanted.item}`;
|
|
53
|
+
const itemFields = (wanted) => Effect.gen(function* itemFields() {
|
|
54
|
+
const inVault = yield* vaultId(wanted);
|
|
55
|
+
const itemId = yield* onlyId(yield* Cache.get(items, inVault), wanted.item, itemRoute(wanted));
|
|
56
|
+
return yield* routes.fields(inVault, itemId);
|
|
57
|
+
});
|
|
58
|
+
const redacted = (wanted, value) => Redacted.make(value, {
|
|
59
|
+
label: `${wanted.vault}/${wanted.item}/${wanted.field}`,
|
|
60
|
+
});
|
|
61
|
+
const readItem = Effect.fn("Secrets.readItem")(function* readItem(wanted) {
|
|
62
|
+
const fields = valued(yield* itemFields(wanted));
|
|
63
|
+
const repeated = fields.find(({ label }, index) => fields.findIndex((other) => other.label === label) !== index);
|
|
64
|
+
if (repeated !== undefined) {
|
|
65
|
+
return yield* refused(fieldRoute({ ...wanted, field: repeated.label }), AMBIGUOUS);
|
|
66
|
+
}
|
|
67
|
+
return Object.fromEntries(fields.map(({ label, value }) => [
|
|
68
|
+
label,
|
|
69
|
+
redacted({ ...wanted, field: label }, value),
|
|
70
|
+
]));
|
|
71
|
+
});
|
|
72
|
+
const read = Effect.fn("Secrets.read")(function* read(wanted) {
|
|
73
|
+
const { value } = yield* only(valued(yield* itemFields(wanted)).filter(({ label }) => label === wanted.field), fieldRoute(wanted));
|
|
74
|
+
return value === ""
|
|
75
|
+
? yield* refused(fieldRoute(wanted), NOT_FOUND)
|
|
76
|
+
: redacted(wanted, value);
|
|
77
|
+
});
|
|
78
|
+
const writeIn = (inVault, wanted) => Effect.gen(function* writeIn() {
|
|
79
|
+
const [first, ...others] = (yield* routes.items(inVault)).filter(({ name }) => name === wanted.item);
|
|
80
|
+
if (first === undefined) {
|
|
81
|
+
yield* routes.create(inVault, wanted.item, Object.entries(wanted.fields).map(([label, value]) => ({
|
|
82
|
+
label,
|
|
83
|
+
value,
|
|
84
|
+
})));
|
|
85
|
+
return "created";
|
|
86
|
+
}
|
|
87
|
+
if (others.length > 0) {
|
|
88
|
+
return yield* refused(itemRoute(wanted), AMBIGUOUS);
|
|
89
|
+
}
|
|
90
|
+
const patches = yield* fieldPatches(wanted, yield* routes.fields(inVault, first.id));
|
|
91
|
+
yield* routes.patch(inVault, first.id, patches);
|
|
92
|
+
return "updated";
|
|
93
|
+
});
|
|
94
|
+
const write = Effect.fn("Secrets.write")(function* write(wanted) {
|
|
95
|
+
const inVault = yield* vaultId(wanted);
|
|
96
|
+
return yield* Effect.ensuring(writeIn(inVault, wanted), Cache.invalidate(items, inVault));
|
|
97
|
+
});
|
|
98
|
+
return Secrets.of({ read, readItem, write });
|
|
99
|
+
});
|
|
100
|
+
export const layer = (url = CONNECT_URL) => Layer.effect(Secrets, make(url));
|
|
101
|
+
export const keychainToken = (service) => Layer.succeed(ConnectToken, readKeychainToken(service));
|
|
102
|
+
export const live = (repo, { token = keychainService(repo), url = CONNECT_URL, } = {}) => layer(url).pipe(Layer.provide([keychainToken(token), FetchHttpClient.layer]));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Redacted from "effect/Redacted";
|
|
3
|
+
export declare const SECURITY_FRAMEWORK = "/System/Library/Frameworks/Security.framework/Security";
|
|
4
|
+
export declare const KEYCHAIN_ACCOUNT = "connect";
|
|
5
|
+
export declare const keychainService: (repo: string) => string;
|
|
6
|
+
export declare const readKeychainToken: (service: string) => Effect.Effect<Redacted.Redacted<string>, import("./refused.ts").ConnectRefused, never>;
|
package/dist/keychain.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Redacted from "effect/Redacted";
|
|
3
|
+
import { KEYCHAIN_MISSING, NEEDS_BUN, refused } from "./refused.js";
|
|
4
|
+
export const SECURITY_FRAMEWORK = "/System/Library/Frameworks/Security.framework/Security";
|
|
5
|
+
export const KEYCHAIN_ACCOUNT = "connect";
|
|
6
|
+
export const keychainService = (repo) => `${repo}-connect-read`;
|
|
7
|
+
const security = (route) => Effect.tryPromise({
|
|
8
|
+
try: () => import("bun:ffi"),
|
|
9
|
+
catch: () => refused(route, NEEDS_BUN),
|
|
10
|
+
}).pipe(Effect.flatMap((ffi) => Effect.acquireRelease(Effect.try({
|
|
11
|
+
try: () => ({
|
|
12
|
+
ffi,
|
|
13
|
+
library: ffi.dlopen(SECURITY_FRAMEWORK, {
|
|
14
|
+
SecKeychainFindGenericPassword: {
|
|
15
|
+
args: [
|
|
16
|
+
ffi.FFIType.ptr,
|
|
17
|
+
ffi.FFIType.u32,
|
|
18
|
+
ffi.FFIType.ptr,
|
|
19
|
+
ffi.FFIType.u32,
|
|
20
|
+
ffi.FFIType.ptr,
|
|
21
|
+
ffi.FFIType.ptr,
|
|
22
|
+
ffi.FFIType.ptr,
|
|
23
|
+
ffi.FFIType.ptr,
|
|
24
|
+
],
|
|
25
|
+
returns: ffi.FFIType.i32,
|
|
26
|
+
},
|
|
27
|
+
SecKeychainItemFreeContent: {
|
|
28
|
+
args: [ffi.FFIType.ptr, ffi.FFIType.ptr],
|
|
29
|
+
returns: ffi.FFIType.i32,
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
catch: () => refused(route, NEEDS_BUN),
|
|
34
|
+
}), ({ library }) => Effect.sync(() => library.close()))));
|
|
35
|
+
export const readKeychainToken = Effect.fn("Keychain.readToken")(function* readToken(service) {
|
|
36
|
+
const route = `keychain ${service}`;
|
|
37
|
+
const { ffi, library } = yield* security(route);
|
|
38
|
+
const serviceName = new TextEncoder().encode(service);
|
|
39
|
+
const accountName = new TextEncoder().encode(KEYCHAIN_ACCOUNT);
|
|
40
|
+
const length = new Uint32Array(1);
|
|
41
|
+
const data = new BigUint64Array(1);
|
|
42
|
+
const status = library.symbols.SecKeychainFindGenericPassword(null, serviceName.length, ffi.ptr(serviceName), accountName.length, ffi.ptr(accountName), ffi.ptr(length), ffi.ptr(data), null);
|
|
43
|
+
if (status !== 0) {
|
|
44
|
+
return yield* refused(route, status);
|
|
45
|
+
}
|
|
46
|
+
const address = ffi.read.ptr(ffi.ptr(data), 0);
|
|
47
|
+
const [size = 0] = length;
|
|
48
|
+
const copied = () => {
|
|
49
|
+
const bytes = new Uint8Array(ffi.toArrayBuffer(address, 0, size));
|
|
50
|
+
const text = new TextDecoder().decode(bytes).trim();
|
|
51
|
+
bytes.fill(0);
|
|
52
|
+
return text;
|
|
53
|
+
};
|
|
54
|
+
const token = yield* Effect.ensuring(Effect.sync(() => (size === 0 ? "" : copied())), Effect.sync(() => library.symbols.SecKeychainItemFreeContent(null, address)));
|
|
55
|
+
return token === ""
|
|
56
|
+
? yield* refused(route, KEYCHAIN_MISSING)
|
|
57
|
+
: Redacted.make(token, { label: service });
|
|
58
|
+
}, Effect.scoped);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
export declare const NO_ANSWER = 0;
|
|
3
|
+
export declare const NOT_FOUND = 404;
|
|
4
|
+
export declare const AMBIGUOUS = 409;
|
|
5
|
+
export declare const NEEDS_BUN = -4;
|
|
6
|
+
export declare const KEYCHAIN_DENIED = -128;
|
|
7
|
+
export declare const KEYCHAIN_MISSING = -25300;
|
|
8
|
+
declare const ConnectRefused_base: Schema.Class<ConnectRefused, Schema.TaggedStruct<"ConnectRefused", {
|
|
9
|
+
readonly route: Schema.String;
|
|
10
|
+
readonly status: Schema.Number;
|
|
11
|
+
readonly message: Schema.String;
|
|
12
|
+
}>, import("effect/Cause").YieldableError>;
|
|
13
|
+
export declare class ConnectRefused extends ConnectRefused_base {
|
|
14
|
+
}
|
|
15
|
+
export declare const refused: (route: string, status: number) => ConnectRefused;
|
|
16
|
+
export {};
|
package/dist/refused.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
export const NO_ANSWER = 0;
|
|
3
|
+
export const NOT_FOUND = 404;
|
|
4
|
+
export const AMBIGUOUS = 409;
|
|
5
|
+
export const NEEDS_BUN = -4;
|
|
6
|
+
export const KEYCHAIN_DENIED = -128;
|
|
7
|
+
export const KEYCHAIN_MISSING = -25_300;
|
|
8
|
+
const MEANINGS = {
|
|
9
|
+
[NO_ANSWER]: "no answer from Connect",
|
|
10
|
+
200: "an answer Connect does not send",
|
|
11
|
+
401: "the token was refused",
|
|
12
|
+
403: "the door refused it, or Connect did for the token's access",
|
|
13
|
+
[NOT_FOUND]: "not found",
|
|
14
|
+
[AMBIGUOUS]: "more than one entry has this name",
|
|
15
|
+
429: "over the door's 60 calls a minute",
|
|
16
|
+
[NEEDS_BUN]: "reading the Keychain needs bun on macOS",
|
|
17
|
+
[KEYCHAIN_DENIED]: "the Keychain prompt was denied",
|
|
18
|
+
[KEYCHAIN_MISSING]: "no token in the Keychain",
|
|
19
|
+
};
|
|
20
|
+
export class ConnectRefused extends Schema.TaggedError()("ConnectRefused", {
|
|
21
|
+
route: Schema.String,
|
|
22
|
+
status: Schema.Number,
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
}) {
|
|
25
|
+
}
|
|
26
|
+
export const refused = (route, status) => {
|
|
27
|
+
const meaning = MEANINGS[status];
|
|
28
|
+
return new ConnectRefused({
|
|
29
|
+
route,
|
|
30
|
+
status,
|
|
31
|
+
message: meaning === undefined
|
|
32
|
+
? `${route} failed with status ${status}`
|
|
33
|
+
: `${route} failed with status ${status}: ${meaning}`,
|
|
34
|
+
});
|
|
35
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milkenjoyer/secrets-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The one 1Password Connect client for birbprophet's repos",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/birbprophet/secrets.git",
|
|
8
|
+
"directory": "client"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
26
|
+
"prepack": "bun run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@distilled.cloud/core": "1.0.0-rc.18",
|
|
30
|
+
"@distilled.cloud/onepassword": "1.0.0-rc.18"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"effect": "^4.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@effect/platform-node": "4.1.0",
|
|
37
|
+
"@effect/vitest": "4.1.0",
|
|
38
|
+
"@types/bun": "1.3.1",
|
|
39
|
+
"effect": "4.1.0",
|
|
40
|
+
"typescript": "6.0.3"
|
|
41
|
+
}
|
|
42
|
+
}
|