photos: immich accounts are configured from the ui, not the environment

IMMICH_URL/IMMICH_API_KEY lived in the platform-wide .env, which was wrong
twice over: bun auto-loads .env into every process started in this directory,
so `officer` itself held an immich credential it has no code to use — and
connecting a library was a shell task on the server rather than something the
owner could do from the app.

it is a registry, not a single connection: any number of labelled accounts with
one selected, the same shape headscale_servers uses. two keys against the same
instance (one per immich user) is the ordinary case, so the label is what has to
be unique, not the url. one active account per owner is enforced by a partial
unique index rather than by convention.

keys are encrypted at rest and write-only across the sidecar boundary — no route
returns one, masked or otherwise. every save is validated against the live
instance first, so a wrong or under-scoped key is a 400 with the reason instead
of a stored row that makes every later screen fail mysteriously.

the drizzle snapshot under migrations/ is regenerated; nothing applies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:05:58 +00:00
co-authored by Claude Opus 5
parent c77e7ee598
commit 632d5a1c1f
17 changed files with 3033 additions and 1059 deletions
@@ -37,33 +37,11 @@ CREATE TABLE "users" (
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "dock_configs" (
"user_id" integer PRIMARY KEY NOT NULL,
"paths" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"server_integration_id" integer,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider")
);
--> statement-breakpoint
CREATE TABLE "user_settings" (
"user_id" integer PRIMARY KEY NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_state" (
"user_id" integer PRIMARY KEY NOT NULL,
"state" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
CREATE TABLE "chat_session_events" (
"id" bigserial PRIMARY KEY NOT NULL,
"session_id" text NOT NULL,
"event" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "dashboard_defaults" (
@@ -89,19 +67,6 @@ CREATE TABLE "dashboards" (
CONSTRAINT "uq_dashboards_user_id" UNIQUE("user_id","id")
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"slug" text NOT NULL,
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug")
);
--> statement-breakpoint
CREATE TABLE "screens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
@@ -113,59 +78,6 @@ CREATE TABLE "screens" (
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "queue_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"lane" text NOT NULL,
"type" text NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"current_step" integer DEFAULT 0 NOT NULL,
"steps" jsonb DEFAULT '[]'::jsonb NOT NULL,
"meta" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "task_logs" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_name" text NOT NULL,
"task_dir_name" text NOT NULL,
"entry_name" text NOT NULL,
"entry_type" text NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"is_error" boolean DEFAULT false NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"started_at" timestamp with time zone NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "terminal_containers" (
"user_id" integer PRIMARY KEY NOT NULL,
"docker_id" text NOT NULL,
"port" integer NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_config" (
"key" text PRIMARY KEY NOT NULL,
"value" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider")
);
--> statement-breakpoint
CREATE TABLE "email_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
@@ -185,30 +97,18 @@ CREATE TABLE "email_accounts" (
CONSTRAINT "uq_email_accounts_user_email" UNIQUE("user_id","email")
);
--> statement-breakpoint
CREATE TABLE "pipeline_jobs" (
"id" text PRIMARY KEY NOT NULL,
CREATE TABLE "headscale_servers" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_dir_name" text NOT NULL,
"task_name" text NOT NULL,
"mode" text DEFAULT 'pipeline' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
"cwd" text,
"config" jsonb NOT NULL,
"progress" jsonb,
"total_cost" jsonb,
"error" text,
"exit_code" integer,
"name" text NOT NULL,
"url" text NOT NULL,
"api_key" text NOT NULL,
"version" text,
"is_active" boolean DEFAULT false NOT NULL,
"last_seen_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "chat_session_events" (
"id" bigserial PRIMARY KEY NOT NULL,
"session_id" text NOT NULL,
"event" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_headscale_servers_user_url" UNIQUE("user_id","url")
);
--> statement-breakpoint
CREATE TABLE "music_favorites" (
@@ -251,6 +151,173 @@ CREATE TABLE "music_playlists" (
CONSTRAINT "uq_music_playlists_user_name" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "push_devices" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"token" text NOT NULL,
"platform" text NOT NULL,
"environment" text DEFAULT 'production' NOT NULL,
"bundle_id" text NOT NULL,
"app_slug" text NOT NULL,
"failure_count" integer DEFAULT 0 NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_push_devices_token_bundle" UNIQUE("token","bundle_id"),
CONSTRAINT "ck_push_devices_platform" CHECK ("push_devices"."platform" IN ('ios', 'android')),
CONSTRAINT "ck_push_devices_environment" CHECK ("push_devices"."environment" IN ('production', 'sandbox'))
);
--> statement-breakpoint
CREATE TABLE "queue_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"lane" text NOT NULL,
"type" text NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"current_step" integer DEFAULT 0 NOT NULL,
"steps" jsonb DEFAULT '[]'::jsonb NOT NULL,
"meta" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "task_logs" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_name" text NOT NULL,
"task_dir_name" text NOT NULL,
"entry_name" text NOT NULL,
"entry_type" text NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"is_error" boolean DEFAULT false NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"started_at" timestamp with time zone NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "terminal_containers" (
"user_id" integer PRIMARY KEY NOT NULL,
"docker_id" text NOT NULL,
"port" integer NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "photos_config" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"url" text NOT NULL,
"api_key" text NOT NULL,
"version" text,
"last_seen_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pipeline_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_dir_name" text NOT NULL,
"task_name" text NOT NULL,
"mode" text DEFAULT 'pipeline' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
"cwd" text,
"config" jsonb NOT NULL,
"progress" jsonb,
"total_cost" jsonb,
"error" text,
"exit_code" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "server_config" (
"key" text PRIMARY KEY NOT NULL,
"value" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider"),
CONSTRAINT "ck_server_integrations_provider" CHECK ("server_integrations"."provider" IN ('google', 'apify'))
);
--> statement-breakpoint
CREATE TABLE "soulseek_browse_dirs" (
"id" serial PRIMARY KEY NOT NULL,
"snapshot_id" integer NOT NULL,
"name" text NOT NULL,
"parent_path" text,
"depth" integer DEFAULT 1 NOT NULL,
"label" text DEFAULT '' NOT NULL,
"child_count" integer DEFAULT 0 NOT NULL,
"file_count" integer DEFAULT 0 NOT NULL,
"total_size" bigint DEFAULT 0 NOT NULL,
"subtree_file_count" integer DEFAULT 0 NOT NULL,
"subtree_size" bigint DEFAULT 0 NOT NULL,
"files" jsonb DEFAULT '[]'::jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "soulseek_browse_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"username" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"error" text,
"directory_count" integer DEFAULT 0 NOT NULL,
"file_count" integer DEFAULT 0 NOT NULL,
"total_size" bigint DEFAULT 0 NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "uq_soulseek_browse_snapshots_user_username" UNIQUE("user_id","username")
);
--> statement-breakpoint
CREATE TABLE "soulseek_favorites" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"username" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_soulseek_favorites_user_username" UNIQUE("user_id","username")
);
--> statement-breakpoint
CREATE TABLE "dock_configs" (
"user_id" integer PRIMARY KEY NOT NULL,
"paths" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"server_integration_id" integer,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider"),
CONSTRAINT "ck_user_integrations_provider" CHECK ("user_integrations"."provider" IN ('google', 'browser-relay'))
);
--> statement-breakpoint
CREATE TABLE "user_settings" (
"user_id" integer PRIMARY KEY NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_state" (
"user_id" integer PRIMARY KEY NOT NULL,
"state" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "vault_tokens" (
"user_id" integer PRIMARY KEY NOT NULL,
"access_token" text NOT NULL,
@@ -267,34 +334,93 @@ CREATE TABLE "vault_unlock_keys" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wallet_chain_cache" (
"wallet_id" integer PRIMARY KEY NOT NULL,
"snapshot" jsonb,
"synced_at" timestamp with time zone,
"last_error" text,
"last_error_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "wallet_frozen_utxos" (
"id" serial PRIMARY KEY NOT NULL,
"wallet_id" integer NOT NULL,
"outpoint" text NOT NULL,
"reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_wallet_frozen_utxos_wallet_outpoint" UNIQUE("wallet_id","outpoint")
);
--> statement-breakpoint
CREATE TABLE "wallet_labels" (
"id" serial PRIMARY KEY NOT NULL,
"wallet_id" integer NOT NULL,
"kind" text NOT NULL,
"ref" text NOT NULL,
"label" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_wallet_labels_wallet_kind_ref" UNIQUE("wallet_id","kind","ref")
);
--> statement-breakpoint
CREATE TABLE "wallet_wallets" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"network" text DEFAULT 'bitcoin' NOT NULL,
"config" text,
"seed_envelope" text,
"fingerprint" text,
"xpubs" jsonb,
"default_bip" integer DEFAULT 84 NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_wallet_wallets_user_name" UNIQUE("user_id","name")
);
--> statement-breakpoint
ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "headscale_servers" ADD CONSTRAINT "headscale_servers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "push_devices" ADD CONSTRAINT "push_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photos_config" ADD CONSTRAINT "photos_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "soulseek_browse_dirs" ADD CONSTRAINT "soulseek_browse_dirs_snapshot_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."soulseek_browse_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "soulseek_browse_snapshots" ADD CONSTRAINT "soulseek_browse_snapshots_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "soulseek_favorites" ADD CONSTRAINT "soulseek_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_server_integration_id_server_integrations_id_fk" FOREIGN KEY ("server_integration_id") REFERENCES "public"."server_integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vault_unlock_keys" ADD CONSTRAINT "vault_unlock_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_chain_cache" ADD CONSTRAINT "wallet_chain_cache_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_frozen_utxos" ADD CONSTRAINT "wallet_frozen_utxos_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_labels" ADD CONSTRAINT "wallet_labels_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_wallets" ADD CONSTRAINT "wallet_wallets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");--> statement-breakpoint
CREATE UNIQUE INDEX "uq_headscale_servers_one_active" ON "headscale_servers" USING btree ("user_id") WHERE "headscale_servers"."is_active";--> statement-breakpoint
CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint
CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position");--> statement-breakpoint
CREATE INDEX "idx_push_devices_user" ON "push_devices" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_queue_jobs_status_lane" ON "queue_jobs" USING btree ("status","lane");--> statement-breakpoint
CREATE INDEX "idx_queue_jobs_user" ON "queue_jobs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_task_logs_user_started" ON "task_logs" USING btree ("user_id","started_at");--> statement-breakpoint
CREATE UNIQUE INDEX "photos_config_user_idx" ON "photos_config" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_pipeline_jobs_user_created" ON "pipeline_jobs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_pipeline_jobs_status" ON "pipeline_jobs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");--> statement-breakpoint
CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint
CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position");
CREATE INDEX "idx_soulseek_browse_dirs_snapshot_name" ON "soulseek_browse_dirs" USING btree ("snapshot_id","name");--> statement-breakpoint
CREATE INDEX "idx_soulseek_browse_dirs_snapshot_parent" ON "soulseek_browse_dirs" USING btree ("snapshot_id","parent_path","name");--> statement-breakpoint
CREATE UNIQUE INDEX "uq_wallet_wallets_one_active" ON "wallet_wallets" USING btree ("user_id") WHERE "wallet_wallets"."is_active";
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1785338790645,
"tag": "0000_crazy_elektra",
"when": 1785771021535,
"tag": "0000_new_princess_powerful",
"breakpoints": true
}
]
+11
View File
@@ -141,6 +141,17 @@ export {
recordHeadscaleProbe,
} from './queries/headscale';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
export {
listPhotosAccounts,
getActivePhotosCredentials,
getPhotosCredentials,
createPhotosAccount,
updatePhotosAccount,
setActivePhotosAccount,
deletePhotosAccount,
recordPhotosProbe,
} from './queries/photos';
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
export {
getVaultTokens,
setVaultTokens,
@@ -0,0 +1,174 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { photosConfig } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from
// at-rest ciphertext happens here. See ../crypto.ts and ../schema/photos.ts.
//
// Two return types, and the split is the safety property:
// PhotosAccount — safe to serialize to the browser. Has NO api key field at all, not even a masked one.
// PhotosCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned by a route.
// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
// response the moment someone forgot to strip it.
export type PhotosAccount = {
id: number;
label: string;
url: string;
version: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type PhotosCredentials = { id: number; label: string; url: string; apiKey: string };
const accountCols = {
id: photosConfig.id,
label: photosConfig.label,
url: photosConfig.url,
version: photosConfig.version,
isActive: photosConfig.isActive,
lastSeenAt: photosConfig.lastSeenAt,
createdAt: photosConfig.createdAt,
};
/** Every account the owner has added, active first then newest. Never includes the API key. */
export async function listPhotosAccounts(userId: number): Promise<PhotosAccount[]> {
return db
.select(accountCols)
.from(photosConfig)
.where(eq(photosConfig.userId, userId))
.orderBy(desc(photosConfig.isActive), desc(photosConfig.createdAt));
}
/** The selected account with its key decrypted, or null when none is added. */
export async function getActivePhotosCredentials(userId: number): Promise<PhotosCredentials | null> {
const [row] = await db
.select()
.from(photosConfig)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret(row.apiKey) };
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
export async function getPhotosCredentials(userId: number, id: number): Promise<PhotosCredentials | null> {
const [row] = await db
.select()
.from(photosConfig)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret(row.apiKey) };
}
type CreatePhotosAccountParams = {
userId: number;
label: string;
url: string;
apiKey: string;
version: string | null;
/** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */
activate: boolean;
};
/** Add an account. The key is encrypted before write; the returned row carries no key. */
export async function createPhotosAccount(params: CreatePhotosAccountParams): Promise<PhotosAccount> {
const { userId, label, url, apiKey, version, activate } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(photosConfig)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
}
const [row] = await tx
.insert(photosConfig)
.values({
userId,
label,
url,
apiKey: encryptSecret(apiKey),
version,
isActive: activate,
lastSeenAt: version ? new Date() : null,
})
.returning(accountCols);
return row!;
});
}
type UpdatePhotosAccountParams = { label?: string; url?: string; apiKey?: string; version?: string | null };
/** Edit an account. Omitted fields are left alone; a supplied key is re-encrypted. */
export async function updatePhotosAccount(
userId: number,
id: number,
params: UpdatePhotosAccountParams,
): Promise<PhotosAccount | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.label !== undefined) set.label = params.label;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
if (params.version !== undefined) set.version = params.version;
const [row] = await db
.update(photosConfig)
.set(set)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
.returning(accountCols);
return row ?? null;
}
/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */
export async function setActivePhotosAccount(userId: number, id: number): Promise<PhotosAccount | null> {
return db.transaction(async (tx) => {
await tx
.update(photosConfig)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
const [row] = await tx
.update(photosConfig)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
.returning(accountCols);
return row ?? null;
});
}
/**
* Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the
* account in use would leave the owner with accounts added but none selected, which reads as "not connected"
* and is a confusing place to land.
*/
export async function deletePhotosAccount(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx
.delete(photosConfig)
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
.returning({ id: photosConfig.id, wasActive: photosConfig.isActive });
if (!deleted) return false;
if (deleted.wasActive) {
const [next] = await tx
.select({ id: photosConfig.id })
.from(photosConfig)
.where(eq(photosConfig.userId, userId))
.orderBy(desc(photosConfig.createdAt))
.limit(1);
if (next) {
await tx.update(photosConfig).set({ isActive: true, updatedAt: new Date() }).where(eq(photosConfig.id, next.id));
}
}
return true;
});
}
/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */
export async function recordPhotosProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(photosConfig)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
}
@@ -6,6 +6,7 @@ export * from './headscale';
export * from './music';
export * from './notify';
export * from './operations';
export * from './photos';
export * from './pipeline-jobs';
export * from './server';
export * from './soulseek';
@@ -0,0 +1,49 @@
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
// The Immich accounts behind /photos, for the officer-photos sidecar.
//
// This used to be IMMICH_URL + IMMICH_API_KEY in the platform-wide `.env`, which was wrong twice over: Bun
// auto-loads `.env` into EVERY process started in the platform directory, so `officer` itself held an Immich
// credential it has no code to use — and connecting a photo library was a shell task on the server rather
// than something the owner could do from the app.
//
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
// same shape headscale_servers uses. Two accounts on the same instance is the normal case (one key per
// Immich user), which is why the uniqueness below is on the label and not on the URL.
//
// `api_key` is encrypted at rest via ../crypto.ts. An Immich key can read and delete the entire library, so a
// DB dump must not hand it over. Encryption is confined to queries/photos.ts; nothing outside that file sees
// ciphertext, and no route ever returns the key at all.
export const photosConfig = pgTable(
'photos_config',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
label: text('label').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
url: text('url').notNull(),
apiKey: text('api_key').notNull(), // encrypted
/** Immich version seen at the last successful probe — shown in the UI, never used for behaviour. */
version: text('version'),
isActive: boolean('is_active').notNull().default(false),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not
// unique on url: several keys against one instance is the whole point.
unique('uq_photos_config_user_label').on(t.userId, t.label),
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a
// bug there fails loudly here instead of silently leaving two accounts active and the UI picking one.
uniqueIndex('uq_photos_config_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);