Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
path = extensions
url = https://github.com/lumibase-ai/extensions
branch = main
[submodule "apps/marketplace"]
path = apps/marketplace
url = git@github.com:lumibase-ai/marketplace.git
branch = main
[submodule "apps/enterprise"]
path = apps/enterprise
url = https://github.com/lumibase-ai/enterprise-core.git
Expand Down
1 change: 1 addition & 0 deletions .kiro/specs/admin-setup-wizard/setup-impact.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Trạng thái: `pending` (chưa làm) · `in-progress` · `done` (setup + backfi
| 33 | collection-create-modes | v0.9.x (proposal) | Bước chọn chế độ tạo collection (View / DB View / Flexible View) + Default_Field_Catalogue + Localize_Dropdown dùng chung. View_Mode không cần khởi tạo. DB/Flexible mode đề xuất 2 cột mới `collections.read_only`/`source_object` | pending | [collection-create-modes/tasks.md](../collection-create-modes/tasks.md) Phase 5.1 | Rà soát 2026-06-29 (PROPOSAL, chưa code). (1) Có thể cần seed `localize` field type khi nền tảng field-localization xong (chờ ADR). (2) Localize_Dropdown đọc Tenant_Locales từ feature #34 — chưa có nguồn truth chính thức (fallback `['en','vi']` hard-code hiện tại). (4) Bước UI mới = mode selector TRƯỚC wizard (không thuộc setup wizard). (6) DB/Flexible mode cần migration thủ công 2 cột (`read_only`,`source_object`) → backfill default an toàn (`false`/`null`); View_Mode KHÔNG migration. Phụ thuộc #34, #35 |
| 34 | tenant-localization-config | v0.9.x (proposal) | Nguồn truth Tenant_Locales (khuyến nghị `settings.key='locales'`) + UI Settings → Languages + sửa Admin_Path. Lấp GAP: hiện không có bảng/API available-locales, `settings.get('locales')` fallback hard-code `['en','vi']` | pending | [tenant-localization-config/tasks.md](../tenant-localization-config/tasks.md) Phase 5.1 | Rà soát 2026-06-29 (PROPOSAL, chưa code). (1) Setup KHỞI TẠO `settings.locales=[default_language]` khi xong (Req 4.2). (2) Settings key `locales` mới — operator cần biết. (4) Tùy chọn thêm bước "ngôn ngữ" vào setup wizard; nếu không thì default `[default_language]`. (6) Nếu dùng `settings` (khuyến nghị) → KHÔNG migration; instance cũ thiếu key → fallback `[default_language]`. Admin_Path từ `/setup/path` thành giá trị sửa-được. Phụ thuộc lẫn nhau với #33 |
| 35 | db-view-introspection | v0.9.x (proposal) | Endpoint introspection DB (`/db/objects`, `/collections/:name/introspect`) + chấm than (⚠) cho Uncatalogued_Field + bootstrap-on-click tạo record `fields`. Lấp GAP: hiện không có introspection, field chỉ đọc từ bảng `fields` | pending | [db-view-introspection/tasks.md](../db-view-introspection/tasks.md) Phase 5.1 | Rà soát 2026-06-29 (PROPOSAL, chưa code). (1) KHÔNG seed. (2) KHÔNG settings key. (3) Quyền dùng = quyền quản trị schema sẵn có; bootstrap qua agent cần HITL `ai_approvals` (rule #4). (4) KHÔNG bước setup wizard mới. (5) Có thể cần capability flag báo runtime hỗ trợ introspection. (6) KHÔNG bảng mới của riêng nó (đọc `information_schema`/`PRAGMA`); dùng chung 2 cột với #33. Adapter introspect qua runtime abstraction (rule #3). Phụ thuộc #33 |
| 36 | (marketplace community: install-trust/download/vote/submit) | v0.16.x | Đã rà soát: mở rộng marketplace — badge `verified` (trusted source: signature + publisher key + hash) trên catalog, nút download package (`GET /extensions/:slug/download`, bump `downloadCount`, 302 → bundle), upvote (`POST`/`DELETE /extensions/:slug/vote`, idempotent qua unique `(userId, marketplaceSlug)`), community submit (`POST /submit` → row global `pending`/unpublished) + moderation (`GET /submissions`, `POST /submissions/:id/review` cần `extensions:configure`). Thêm cột `extensions.download_count`/`submission_status`/`submitted_by` + bảng mới `extension_votes` (migration `0003_marketplace_votes_downloads`) | n/a | — | Rà soát 2026-07-04. (1) KHÔNG seed: votes/submissions tạo theo nhu cầu; `download_count` mặc định 0. (2) KHÔNG feature-flag/settings key mới. (3) KHÔNG policy/grant DB mới — vote/submit chỉ cần `withAuth` (user đã đăng nhập); moderation dùng lại capability `extensions:configure` sẵn có (không role mặc định nào cần bổ sung ngoài admin đã có). (4) KHÔNG bước UI wizard mới — thao tác ở Studio → Marketplace / site công khai sau setup. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG cần backfill dữ liệu: migration `0003` chỉ `ADD COLUMN` (có default `0`/nullable) + `CREATE TABLE extension_votes` (forward-only qua journal); instance cũ nhận `download_count=0`, bảng votes rỗng, listing chưa ký hiện `verified=false`. **Submodule move** của `apps/marketplace` → `lumibase-ai/marketplace` là thao tác git thuần (script `scripts/marketplace-submodule-migration.sh`), KHÔNG ảnh hưởng setup/DB |
| 37 | (security hardening: file upload) | v0.17.x | Đã rà soát: mở rộng `withFileUploadPolicy` để phủ luôn `POST /api/v1/media/:key` (trước đây chỉ phủ `/files`): chặn public role đẩy bytes, cap size theo bytes THẬT (không chỉ `Content-Length`), allowlist MIME, đối chiếu đuôi↔MIME, content-sniff magic bytes, và chặn SVG chứa mã động (`<script>`/`on*=`/`javascript:`/`<foreignObject>`/XXE). Serve media dưới `Content-Disposition: attachment` + `nosniff`; storage adapter map `contentType` sang field native R2/S3 để round-trip đúng. Allowlist/size giờ cấu hình được **per-site qua DB** (settings key `upload_policy`) + trang Studio **Settings → Uploads** + endpoint `GET/PUT /api/v1/uploads/config`; guard đọc DB→env→default (fail-safe). Catalogue đuôi/MIME gom về `@lumibase/shared/schemas` (single source cho server + picker). Code + test + docs only | n/a | — | Rà soát 2026-07-05. (1) KHÔNG seed: không cần seed row `upload_policy` — vắng row → guard fallback env→default (allowlist ảnh/PDF/CSV/text + 10 MiB). (2) **Settings key mới `upload_policy`** (scope `site`, lưu trong bảng `settings` sẵn có) — operator có thể sửa qua Settings → Uploads; env `FILE_UPLOAD_MAX_BYTES`/`FILE_UPLOAD_ALLOWED_MIME_TYPES` vẫn là fallback. (3) KHÔNG policy/grant DB mới — PUT gated bằng `requireSiteAdmin`; `/media` vẫn dựa RBAC `media:create` (default-deny). (4) KHÔNG bước setup wizard mới (trang nằm trong Settings, không phải wizard). (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG backfill/migration: `upload_policy` dùng bảng `settings` (đã tồn tại), instance cũ thiếu row → fallback tự động; object media cũ (content-type chỉ trong custom metadata) vẫn serve đúng nhờ fallback `obj.metadata?.contentType`. Xem `docs/en/security/runtime-security-guards-plan.md` mục "File upload policy" |

## Lưu ý backfill
Expand Down
60 changes: 51 additions & 9 deletions apps/cms/src/routes/__tests__/marketplace-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import type { Database } from '@lumibase/database';
import { extensionVotes, type Database } from '@lumibase/database';
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import type { AppEnv } from '../../env';
import { marketplaceRouter } from '../marketplace';

function makeDb(rows: unknown[]): Database {
const selectFluent = {
from: () => selectFluent,
where: () => Promise.resolve(rows),
};
type VoteRow = { marketplaceSlug: string; userId: string };

// Table-aware mock: `.from(extensionVotes)` resolves to the vote rows, every
// other table resolves to the extension rows.
function makeDb(rows: unknown[], votes: VoteRow[] = []): Database {
return {
select: () => selectFluent,
select: () => {
let data: unknown[] = rows;
const fluent = {
from: (table: unknown) => {
data = table === extensionVotes ? votes : rows;
return fluent;
},
where: () => Promise.resolve(data),
};
return fluent;
},
} as unknown as Database;
}

function buildApp(rows: unknown[]) {
function buildApp(rows: unknown[], votes: VoteRow[] = []) {
const app = new Hono<AppEnv>();
app.use('*', async (c, next) => {
c.set('siteId', 'site_1');
c.set('db', makeDb(rows));
c.set('db', makeDb(rows, votes));
c.set('runtime', { cache: undefined, search: undefined, queue: undefined } as never);
await next();
});
Expand Down Expand Up @@ -153,6 +162,39 @@ describe('marketplace public catalog routes', () => {
});
});

it('marks a signed listing verified and surfaces download + vote metrics', async () => {
const app = buildApp(
[
row({
signature: 'sig',
publisherKeyId: 'key_1',
bundleSha256: 'a'.repeat(64),
downloadCount: 42,
}),
],
[
{ marketplaceSlug: 'seo-toolkit', userId: 'u1' },
{ marketplaceSlug: 'seo-toolkit', userId: 'u2' },
],
);

const res = await app.request('/api/v1/marketplace/extensions');
const body = (await res.json()) as { data: Array<Record<string, unknown>> };
expect(body.data[0]).toMatchObject({
verified: true,
totalDownloads: 42,
voteCount: 2,
hasVoted: false,
});
});

it('leaves an unsigned listing unverified with zeroed metrics', async () => {
const app = buildApp([row({})]);
const res = await app.request('/api/v1/marketplace/extensions');
const body = (await res.json()) as { data: Array<Record<string, unknown>> };
expect(body.data[0]).toMatchObject({ verified: false, voteCount: 0, totalDownloads: 0 });
});

it('returns 404 for missing extension detail', async () => {
const res = await buildApp([]).request('/api/v1/marketplace/extensions/missing');

Expand Down
262 changes: 262 additions & 0 deletions apps/cms/src/routes/__tests__/marketplace-community.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { extensionVotes, type Database } from '@lumibase/database';
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import type { AppEnv } from '../../env';
import { marketplaceRouter } from '../marketplace';

type Row = Record<string, unknown>;

interface State {
extensions: Row[];
votes: Array<{ marketplaceSlug: string; userId: string }>;
}

/**
* Stateful, table-aware mock covering the mutation chains the community routes
* use: select/insert(onConflictDoNothing|returning)/update(set.where[.returning])/delete.
* It is deliberately predicate-blind — tests are seeded with a single logical
* subject so the coarse mutations (e.g. delete clears votes) behave correctly.
*/
function makeDb(state: State): Database {
return {
select: () => {
let data: unknown[] = state.extensions;
const fluent = {
from: (table: unknown) => {
data = table === extensionVotes ? state.votes : state.extensions;
return fluent;
},
where: () => Promise.resolve(data),
};
return fluent;
},
insert: (table: unknown) => ({
values: (vals: Row | Row[]) => {
const arr = Array.isArray(vals) ? vals : [vals];
return {
onConflictDoNothing: () => {
if (table === extensionVotes) {
for (const v of arr) {
const exists = state.votes.some(
(x) =>
x.userId === v.userId &&
x.marketplaceSlug === v.marketplaceSlug,
);
if (!exists)
state.votes.push(
v as { marketplaceSlug: string; userId: string },
);
}
}
return Promise.resolve();
},
returning: () => {
if (table !== extensionVotes) state.extensions.push(...arr);
return Promise.resolve(arr);
},
};
},
}),
update: () => ({
set: (patch: Row) => ({
where: () => {
const result =
state.extensions.length > 0
? [{ ...state.extensions[0], ...patch }]
: [];
return Object.assign(Promise.resolve(result), {
returning: () => Promise.resolve(result),
});
},
}),
}),
delete: () => ({
where: () => {
state.votes = [];
return Promise.resolve();
},
}),
} as unknown as Database;
}

function buildApp(state: State, userId: string | null) {
const app = new Hono<AppEnv>();
app.use('*', async (c, next) => {
c.set('siteId', 'site_1');
c.set('db', makeDb(state));
c.set('runtime', { cache: undefined } as never);
if (userId) c.set('auth', { userId } as never);
await next();
});
app.route('/api/v1/marketplace', marketplaceRouter);
return app;
}

const published: Row = {
id: 'ext_1',
siteId: null,
name: 'SEO Toolkit',
version: '1.0.0',
type: 'module',
bundleUrl: 'https://cdn.example/seo.js',
bundleSha256: 'a'.repeat(64),
signature: 'sig',
signatureAlg: 'ed25519',
publisherKeyId: 'key_1',
publisher: 'LumiBase',
marketplaceSlug: 'seo-toolkit',
publishedAt: new Date('2026-06-01T00:00:00.000Z'),
downloadCount: 5,
submissionStatus: null,
submittedBy: null,
};

describe('marketplace download', () => {
it('redirects to the bundle by default', async () => {
const app = buildApp({ extensions: [{ ...published }], votes: [] }, null);
const res = await app.request('/api/v1/marketplace/extensions/seo-toolkit/download', {
redirect: 'manual',
});
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe('https://cdn.example/seo.js');
});

it('returns bundle metadata with an incremented count when redirect=0', async () => {
const app = buildApp({ extensions: [{ ...published }], votes: [] }, null);
const res = await app.request(
'/api/v1/marketplace/extensions/seo-toolkit/download?redirect=0',
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toMatchObject({
slug: 'seo-toolkit',
bundleUrl: 'https://cdn.example/seo.js',
bundleSha256: 'a'.repeat(64),
downloadCount: 6,
});
});

it('404s for an unknown slug', async () => {
const app = buildApp({ extensions: [], votes: [] }, null);
const res = await app.request('/api/v1/marketplace/extensions/nope/download');
expect(res.status).toBe(404);
});
});

describe('marketplace voting', () => {
it('requires authentication', async () => {
const app = buildApp({ extensions: [{ ...published }], votes: [] }, null);
const res = await app.request('/api/v1/marketplace/extensions/seo-toolkit/vote', {
method: 'POST',
});
expect(res.status).toBe(401);
});

it('records an upvote and reports the new tally', async () => {
const state: State = { extensions: [{ ...published }], votes: [] };
const app = buildApp(state, 'user_1');
const res = await app.request('/api/v1/marketplace/extensions/seo-toolkit/vote', {
method: 'POST',
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: { slug: 'seo-toolkit', voteCount: 1, hasVoted: true },
});
expect(state.votes).toHaveLength(1);
});

it('is idempotent for a repeat vote from the same user', async () => {
const state: State = { extensions: [{ ...published }], votes: [] };
const app = buildApp(state, 'user_1');
await app.request('/api/v1/marketplace/extensions/seo-toolkit/vote', { method: 'POST' });
const res = await app.request('/api/v1/marketplace/extensions/seo-toolkit/vote', {
method: 'POST',
});
const body = (await res.json()) as { data: { voteCount: number } };
expect(body.data.voteCount).toBe(1);
expect(state.votes).toHaveLength(1);
});

it('removes a vote on DELETE', async () => {
const state: State = {
extensions: [{ ...published }],
votes: [{ marketplaceSlug: 'seo-toolkit', userId: 'user_1' }],
};
const app = buildApp(state, 'user_1');
const res = await app.request('/api/v1/marketplace/extensions/seo-toolkit/vote', {
method: 'DELETE',
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: { slug: 'seo-toolkit', voteCount: 0, hasVoted: false },
});
});

it('404s when voting for an unpublished slug', async () => {
const app = buildApp({ extensions: [], votes: [] }, 'user_1');
const res = await app.request('/api/v1/marketplace/extensions/ghost/vote', {
method: 'POST',
});
expect(res.status).toBe(404);
});
});

describe('marketplace community submission', () => {
const validBody = {
name: 'My Widget',
version: '0.1.0',
type: 'panel',
marketplaceSlug: 'my-widget',
bundleUrl: 'https://cdn.example/my-widget.js',
description: 'A handy widget.',
publisher: 'Acme',
};

it('requires authentication', async () => {
const app = buildApp({ extensions: [], votes: [] }, null);
const res = await app.request('/api/v1/marketplace/submit', {
method: 'POST',
body: JSON.stringify(validBody),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});

it('creates a pending, unpublished listing', async () => {
const state: State = { extensions: [], votes: [] };
const app = buildApp(state, 'user_1');
const res = await app.request('/api/v1/marketplace/submit', {
method: 'POST',
body: JSON.stringify(validBody),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(201);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toMatchObject({
marketplaceSlug: 'my-widget',
submissionStatus: 'pending',
submittedBy: 'user_1',
publishedAt: null,
});
});

it('rejects a slug already live in the catalog', async () => {
const app = buildApp({ extensions: [{ ...published, marketplaceSlug: 'my-widget' }], votes: [] }, 'user_1');
const res = await app.request('/api/v1/marketplace/submit', {
method: 'POST',
body: JSON.stringify(validBody),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(409);
});

it('validates the payload', async () => {
const app = buildApp({ extensions: [], votes: [] }, 'user_1');
const res = await app.request('/api/v1/marketplace/submit', {
method: 'POST',
body: JSON.stringify({ ...validBody, version: 'not-semver' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(400);
});
});
Loading
Loading