Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-media-type-apikey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"medialit": patch
---

The `Media` type no longer has an `apikey` field. The API never returned it, and keys are now stored only as hashes.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ NODE_ENV=development

# The first user, who signs in with a code sent to this address.
EMAIL=admin@example.com
# Optional. An API key created for that user's default app at startup, for
# services that use MediaLit before anyone opens the dashboard. Leave empty to
# create keys in the dashboard. Generate with:
# echo ml_live_$(openssl rand -hex 32)
MEDIALIT_BOOTSTRAP_API_KEY=
# Production: generate with `openssl rand -base64 48`.
BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ bun --filter @medialit/web dev # Dashboard on http://localhost:3000
bun run dev:docs # Docs on http://localhost:3008
```

On its first start, the API creates a user for `EMAIL` and a default app, and logs the app's API key (`"msg":"Admin user created"`). Keep it private: anyone with it can upload to your instance. Sign in to the dashboard with `EMAIL`; the code arrives in Mailpit at <http://127.0.0.1:8025>. The MinIO console is at <http://127.0.0.1:9001> (`medialit` / `medialit-secret`).
On its first start, the API creates a user for `EMAIL` and a default app. Sign in to the dashboard with `EMAIL`; the code arrives in Mailpit at <http://127.0.0.1:8025>. Create an API key in the app's settings, or set `MEDIALIT_BOOTSTRAP_API_KEY` in `apps/api/.env` to have the API create one at startup. The MinIO console is at <http://127.0.0.1:9001> (`medialit` / `medialit-secret`).

If you run the dashboard on another port, set `WEB_ORIGIN` in `apps/api/.env` to match, or sign-in redirects fail.

Expand Down
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ WEB_ORIGIN=http://localhost:3000
# AUTH_COOKIE_DOMAIN=
BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min
EMAIL=admin@example.com
# Optional. An API key the API creates for EMAIL's default app at startup, so
# another service can use MediaLit without anyone opening the dashboard. At
# least 43 characters from [A-Za-z0-9_-]. Generate with:
# echo ml_live_$(openssl rand -hex 32)
# MEDIALIT_BOOTSTRAP_API_KEY=
TEMP_FILE_DIR_FOR_UPLOADS=/tmp/medialit

# MinIO. The S3 clients use path-style requests against CLOUD_ENDPOINT.
Expand Down
2 changes: 1 addition & 1 deletion apps/api/__tests__/mcp/media-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("MCP get_total_storage", () => {
authKind: "apikey",
userId: user._id,
user,
apikey: "test-api-key",
appId: "test-app",
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/api/__tests__/mcp/upload-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function authInfo(user: any) {
authKind: "apikey",
userId: user.id,
user,
apikey: "test-api-key",
appId: "test-app",
},
},
};
Expand Down
20 changes: 11 additions & 9 deletions apps/api/__tests__/mcp/whoami-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const user = {
function extra(authKind: "oauth" | "apikey") {
return {
authInfo: {
extra: { authKind, userId: user._id, user, apikey: "app-secret" },
extra: { authKind, userId: user._id, user, appId: "internal-app" },
},
};
}
Expand All @@ -43,24 +43,25 @@ describe("MCP whoami", () => {
mockPaidPlan();
const queried: unknown[] = [];
const response = await handleWhoamiTool({}, extra("oauth"), {
getApiKey: async (key: string) => {
queried.push(key);
getApp: async (id: string) => {
queried.push(id);
return {
key,
keyId: "app-1",
id,
appId: "app-1",
name: "My Store",
default: true,
deleted: false,
userId: user._id,
} as any;
},
getMediaCount: async ({ userId, apikey }: any) => {
queried.push([userId, apikey]);
getMediaCount: async ({ userId, appId }: any) => {
queried.push([userId, appId]);
return 3;
},
getTotalSpace: async () => 2048,
});

assert.deepEqual(queried, ["app-secret", ["user-1", "app-secret"]]);
assert.deepEqual(queried, ["internal-app", ["user-1", "internal-app"]]);
assert.deepEqual((response as any).structuredContent, {
auth: "oauth",
email: "hi@example.com",
Expand All @@ -69,7 +70,8 @@ describe("MCP whoami", () => {
storage: 2048,
maxStorage: maxStorageAllowedSubscribed,
});
assert.doesNotMatch(response.content[0].text, /app-secret/);
// The app is named by its public ID, never the internal one.
assert.doesNotMatch(response.content[0].text, /internal-app/);
});

test("rejects calls without auth", async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/__tests__/media/utils/get-public-urls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const clearModuleCache = () => {
const createMockMedia = (overrides: Partial<Media> = {}): Media => ({
fileName: "main.jpg",
mediaId: "test-media-id-123",
apikey: "test-apikey",
appId: "test-app",
originalFileName: "original.jpg",
mimeType: "image/jpeg",
size: 1024,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/__tests__/tus/tus-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
function req(user: any) {
return {
user,
apikey: "test-api-key",
appId: "test-app",
group: "default",
};
}
Expand Down
191 changes: 191 additions & 0 deletions apps/api/drizzle/0008_api_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
-- Hashed, revocable API keys (docs/prds/apikey.md §5).
--
-- The old `api_keys` table was both the app (tenant) and its single plaintext
-- key, and every tenant table stored that plaintext key. This migration:
-- 1. renames `api_keys` to `apps` (the tenant), keeping its IDs;
-- 2. creates a new `api_keys` table holding only SHA-256 hashes;
-- 3. turns each app's plaintext key into a hashed `legacy` key, so existing
-- integrations keep working;
-- 4. re-keys media, media settings, signatures and TUS uploads to `apps.id`;
-- 5. drops the plaintext key and the never-enforced restriction columns.
-- The migrator runs it inside one transaction, so any failure leaves the
-- database untouched.

ALTER TABLE "api_keys" RENAME TO "apps";
--> statement-breakpoint
ALTER TABLE "apps" RENAME COLUMN "key_id" TO "app_id";
--> statement-breakpoint
ALTER TABLE "oauth_app_selections" RENAME COLUMN "api_key_id" TO "app_id";
--> statement-breakpoint
-- Renaming a table keeps its constraint and index names. 0001 created them
-- implicitly, so look them up by what they constrain and give them the names
-- Drizzle expects; otherwise the new `api_keys` table would collide with them.
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT c.conname, c.contype, a.attname
FROM pg_constraint c
LEFT JOIN pg_attribute a
ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.conrelid = 'public.apps'::regclass
LOOP
IF r.contype = 'p' AND r.conname <> 'apps_pkey' THEN
EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_pkey');
ELSIF r.contype = 'u' AND r.attname = 'app_id' AND r.conname <> 'apps_app_id_unique' THEN
EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_app_id_unique');
ELSIF r.contype = 'u' AND r.attname = 'key' THEN
EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_key_unique');
ELSIF r.contype = 'f' AND r.attname = 'user_id' AND r.conname <> 'apps_user_id_user_id_fk' THEN
EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_user_id_user_id_fk');
-- PostgreSQL 18+ records NOT NULL as named constraints too.
ELSIF r.contype = 'n' AND r.conname <> 'apps_' || r.attname || '_not_null' THEN
EXECUTE format('ALTER TABLE "apps" RENAME CONSTRAINT %I TO %I', r.conname, 'apps_' || r.attname || '_not_null');
END IF;
END LOOP;

FOR r IN
SELECT c.conname
FROM pg_constraint c
WHERE c.conrelid = 'public.oauth_app_selections'::regclass
AND c.contype = 'f'
AND c.confrelid = 'public.apps'::regclass
AND c.conname <> 'oauth_app_selections_app_id_apps_id_fk'
LOOP
EXECUTE format('ALTER TABLE "oauth_app_selections" RENAME CONSTRAINT %I TO %I', r.conname, 'oauth_app_selections_app_id_apps_id_fk');
END LOOP;
END $$;
--> statement-breakpoint
ALTER INDEX IF EXISTS "api_keys_user_name_uidx" RENAME TO "apps_user_name_uidx";
--> statement-breakpoint
ALTER INDEX IF EXISTS "api_keys_one_default_uidx" RENAME TO "apps_one_default_uidx";
--> statement-breakpoint
CREATE TABLE "api_keys" (
"id" text PRIMARY KEY NOT NULL,
"api_key_id" text NOT NULL,
"app_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"key_prefix" text NOT NULL,
"expires_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_by_type" text NOT NULL,
"created_by_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "api_keys_api_key_id_unique" UNIQUE("api_key_id"),
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash"),
CONSTRAINT "api_keys_created_by_type_check" CHECK ("api_keys"."created_by_type" IN ('user', 'bootstrap', 'legacy'))
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "api_keys_app_id_idx" ON "api_keys" USING btree ("app_id");
--> statement-breakpoint
-- Every existing plaintext key becomes a hashed `legacy` key on its app. Lookup
-- hashes whatever the client sends, so these keep authenticating unchanged.
-- Keys of deleted apps arrive already revoked.
INSERT INTO "api_keys" (
"id", "api_key_id", "app_id", "name", "key_hash", "key_prefix",
"revoked_at", "created_by_type", "created_at"
)
SELECT
gen_random_uuid()::text,
'ak_' || replace(gen_random_uuid()::text, '-', ''),
"id",
'Default key',
encode(sha256(convert_to("key", 'UTF8')), 'hex'),
left("key", 4),
CASE WHEN "deleted" THEN now() ELSE NULL END,
'legacy',
"created_at"
FROM "apps";
--> statement-breakpoint
ALTER TABLE "media" ADD COLUMN "app_id" text;
--> statement-breakpoint
UPDATE "media" AS m SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = m."apikey";
--> statement-breakpoint
ALTER TABLE "media_settings" ADD COLUMN "app_id" text;
--> statement-breakpoint
UPDATE "media_settings" AS s SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = s."apikey";
--> statement-breakpoint
-- Never drop media or settings silently: a row whose key matches no app needs
-- a person to look at it.
DO $$
DECLARE
orphans bigint;
BEGIN
SELECT count(*) INTO orphans FROM "media" WHERE "app_id" IS NULL;
IF orphans > 0 THEN
RAISE EXCEPTION '% media rows reference an API key that matches no app (media.apikey not found in api_keys.key). Fix or delete them, then upgrade again.', orphans;
END IF;
SELECT count(*) INTO orphans FROM "media_settings" WHERE "app_id" IS NULL;
IF orphans > 0 THEN
RAISE EXCEPTION '% media_settings rows reference an API key that matches no app (media_settings.apikey not found in api_keys.key). Fix or delete them, then upgrade again.', orphans;
END IF;
END $$;
--> statement-breakpoint
-- Signatures last a day and TUS upload records a few days. An orphaned one
-- could never be used, so delete it rather than block the upgrade.
ALTER TABLE "signatures" ADD COLUMN "app_id" text;
--> statement-breakpoint
ALTER TABLE "signatures" ADD COLUMN "api_key_id" text;
--> statement-breakpoint
UPDATE "signatures" AS s SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = s."apikey";
--> statement-breakpoint
DELETE FROM "signatures" WHERE "app_id" IS NULL;
--> statement-breakpoint
-- The old schema can't tell key-minted signatures from dashboard ones, so tie
-- them all to the app's legacy key: revoking it then revokes them too.
UPDATE "signatures" AS s SET "api_key_id" = k."id" FROM "api_keys" AS k WHERE k."app_id" = s."app_id" AND k."created_by_type" = 'legacy';
--> statement-breakpoint
ALTER TABLE "tus_uploads" ADD COLUMN "app_id" text;
--> statement-breakpoint
UPDATE "tus_uploads" AS t SET "app_id" = a."id" FROM "apps" AS a WHERE a."key" = t."apikey";
--> statement-breakpoint
DELETE FROM "tus_uploads" WHERE "app_id" IS NULL;
--> statement-breakpoint
DROP INDEX IF EXISTS "media_owner_idx";
--> statement-breakpoint
ALTER TABLE "media" DROP COLUMN "apikey";
--> statement-breakpoint
ALTER TABLE "media_settings" DROP COLUMN "apikey";
--> statement-breakpoint
ALTER TABLE "signatures" DROP COLUMN "apikey";
--> statement-breakpoint
ALTER TABLE "tus_uploads" DROP COLUMN "apikey";
--> statement-breakpoint
ALTER TABLE "media" ALTER COLUMN "app_id" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "media_settings" ALTER COLUMN "app_id" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "signatures" ALTER COLUMN "app_id" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "tus_uploads" ALTER COLUMN "app_id" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "media" ADD CONSTRAINT "media_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "media_settings" ADD CONSTRAINT "media_settings_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "media_settings" ADD CONSTRAINT "media_settings_app_id_unique" UNIQUE("app_id");
--> statement-breakpoint
ALTER TABLE "signatures" ADD CONSTRAINT "signatures_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "signatures" ADD CONSTRAINT "signatures_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "tus_uploads" ADD CONSTRAINT "tus_uploads_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "media_owner_idx" ON "media" USING btree ("user_id", "app_id", "temp");
--> statement-breakpoint
CREATE INDEX "signatures_api_key_id_idx" ON "signatures" USING btree ("api_key_id");
--> statement-breakpoint
ALTER TABLE "apps" DROP COLUMN "key";
--> statement-breakpoint
ALTER TABLE "apps" DROP COLUMN "restriction";
--> statement-breakpoint
ALTER TABLE "apps" DROP COLUMN "http_referrers";
--> statement-breakpoint
ALTER TABLE "apps" DROP COLUMN "ip_addresses";
Loading
Loading