Skip to content

feat(accounts): manage Constructive accounts and API keys from the vault - #21

Merged
pyramation merged 1 commit into
mainfrom
feat/constructive-accounts
Aug 9, 2026
Merged

feat(accounts): manage Constructive accounts and API keys from the vault#21
pyramation merged 1 commit into
mainfrom
feat/constructive-accounts

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

dcrypt becomes an account manager for real Constructive accounts: sign up / sign in through @constructive-io/sdk's generated auth client, and mint and revoke API keys — with every session token and key secret living in the encrypted vault rather than an account.json guarded by whatever the OS happens to offer. Slices 1+2 of https://github.com/constructive-io/constructive-planning/issues/1520; the harness credential provider, biometric unlock and passkeys follow.

@decryption/accounts is the whole seam. AccountManager takes a Vault and an injectable client factory, so the tests exercise the real vault against a fake server and no test needs credentials:

class AccountManager {
  constructor(vault: Vault, options: { createClient?: AuthClientFactory; now?: () => Date } = {})
  signIn/signUp({ endpoint, email, password }): Promise<AccountRecord>
  accessToken(itemId): Promise<string | null>   // null once accessTokenExpiresAt has passed
  createApiKey(accountItemId, { name, expiresIn, accessLevel }): Promise<ApiKeyRecord>
  revealApiKey(itemId): Promise<string>
  revokeApiKey(itemId): Promise<void>           // server first, local item only after it succeeds
}

Two details that matter:

  • The one-time secret never exists outside the vault. createApiKey returns apiKey exactly once from the server, and the manager writes it straight to a concealed field; the returned ApiKeyRecord deliberately carries only keyId/name/expiresAt. Nothing logs it, nothing puts it in a .env.
  • An expired token is treated as absent. accessToken() returns null past its expiry rather than sending a dead bearer, and signOut deletes the token field after the server call, so a locked vault means no credentials at all.

Accounts and keys are new item kinds, so they inherit search, audit, backup and rebuild for free:

 type ItemKind = 'login' | 'note' | ... | 'ssh_key'
+                | 'account' | 'api_key';
 type FieldPurpose = 'username' | 'password' | ... | 'url'
+                | 'token';

That is a new pgpm change (schemas/dcrypt_vault/account_types) with a real revert — Postgres can't drop an enum value, so the revert rebuilds both types without them and lets the column cast fail if a row still uses one. A vault file is deployed once and thereafter restored from its snapshot, so Vault.open also runs ensureSchema() (three ADD VALUE IF NOT EXISTS statements mirroring that deploy file), which is what carries an existing vault forward without a rebuild.

CLI (dcrypt account list|signup|signin|signout|forget, dcrypt account key list|create|reveal|revoke) follows the existing conventions: the account password is refused in argv exactly like the master passphrase (--password-file, --password-stdin, DCRYPT_ACCOUNT_PASSWORD), the endpoint comes from --endpoint or DCRYPT_AUTH_ENDPOINT, and normal output is metadata only — the secret appears solely from an explicit key reveal. Because this is the first command in dcrypt that opens a socket, the top-level usage and the README no longer claim otherwise.

The desktop gets an Accounts tab (Blocks UI) for the same operations, with copy-to-clipboard going through the existing 30s-clearing helper.

Unrelated one-liner, included because it made pnpm typecheck in apps/desktop fail outright: appstash() returns an AppStashResult, not a path, so appDataPath() now returns .root — which also means eraseAll()'s fs.rm(appDataPath()) gets a string instead of an object.

Tests: 13 in @decryption/accounts (fake client + real vault, including lock/reopen and endpoint isolation) and 6 CLI tests covering the argv refusal, missing endpoint and not-found paths.

Link to Devin session: https://app.devin.ai/sessions/04636534e07048089ffb6b78142e12cd
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 9, 2026
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​constructive-io/​sdk@​1.11.98110010098100

View full report

@pyramation
pyramation merged commit f57bcf0 into main Aug 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant