Skip to content

Extension Guardrails — ranh giới khi viết extension cho ODP

Đọc tài liệu này trước khi code một extension (đặc biệt nếu bạn là AI agent). Mục tiêu: làm đúng việc, đúng chỗ; không làm lại thứ ODP đã có; không chạm vào nội bộ core để sau này update core không làm vỡ extension.

Bản đồ kiến trúc + bề mặt extension theo từng phần: Backend API Sitemap. Cách scaffold/build/dev: Extension Development.


0. Checklist trước khi viết bất kỳ dòng code nào

  1. ODP đã có tính năng này chưa? → xem §3. Nếu có, dùng lại, không tự viết.
  2. Cần Hook hay Endpoint?§2. Hook = phản ứng theo sự kiện/biến đổi dữ liệu; Endpoint = thêm HTTP route mới.
  3. Chỉ import từ @odp/api/types. Không import deep path (@odp/api/dist/..., src/...). → §4.
  4. Mọi CRUD (kể cả collection riêng của extension) → ưu tiên cao nhất context.services.ItemsService(collection, { knex, accountability, schema }) với accountability của request. ItemsService chạy được trên collection custom, và chỉ qua nó bạn mới có permission + audit/activity + revision + parsing query (filter/sort/aggregate/relation). Không query thẳng bảng odp_*.
  5. context.database (Knex thô) chỉ dùng khi thật sự cầnItemsService không biểu đạt được (query quá phức tạp, luồng lớn cần transaction bọc thủ công, bulk/aggregation đặc thù). Hiểu rõ: raw DB bỏ qua permission, audit log và parsing query.
  6. Khai báo quyềnpackage.json#odp-extension.permissions; kiểm tra trong endpoint bằng context.validateAppAccess(...).
  7. Không monkey-patch, không mở server/port riêng, không bypass permission.§5.

Nếu một yêu cầu không thể thực hiện trong ranh giới này → dừng và hỏi, đừng "chế" giải pháp luồn lách.


1. Extension là gì — và bề mặt được phép dùng

Một extension là package có package.json với khối odp-extension. 3 loại:

LoạiDùng khi
hookPhản ứng theo sự kiện vòng đời (before/after CRUD, submit version, server start...)
endpointThêm HTTP route mới
bundleGộp cả hook + endpoint trong 1 extension (khuyến nghị)

Toàn bộ những gì extension được phép chạm gói trong ApiExtensionContext (truyền vào hook/endpoint):

ts
import { defineHook, defineEndpoint } from '@odp/api/types';

interface ApiExtensionContext {
  services;          // các service class: ItemsService, UsersService, ... (dùng cho mọi nghiệp vụ)
  emitter;           // onFilter/onAction/onInit (hook) + emitFilter/emitAction/emitInit (phát event)
  database;          // Knex thô — CHỈ khi ItemsService không đủ (xem §2); bỏ qua permission/audit/parsing
  getSchema();       // SchemaOverview hiện tại
  env; logger;       // cấu hình + log
  validateAppAccess; // kiểm tra app-permission trong endpoint
  registry;          // chia sẻ service giữa các extension (provide/consume) — xem §6
}

Hook nhận (context); endpoint nhận (router /* Fastify scoped */, context). Đó là toàn bộ bề mặt được hỗ trợ — ngoài phạm vi này là nội bộ.


2. Làm gì → ở đâu

Bạn cần...DùngKhông làm
CRUD trên một collection (kể cả collection riêng của extension)new context.services.ItemsService(coll, { knex, accountability, schema }) — ưu tiên cao nhấtQuery/Knex thô (mất permission/audit/parsing)
Phản ứng khi item tạo/sửa/xoáHook context.action('items.create' | 'items.update' | 'items.delete', fn) hoặc scoped '<collection>.items.create'Polling DB, trigger DB thủ công
Sửa/validate payload trước khi ghiHook context.filter('items.create', fn) (trả payload đã sửa, hoặc throw để chặn)Sửa dữ liệu sau khi đã ghi
Thêm HTTP routeendpoint: router.get('/path', handler) (prefix tự gắn theo id)Mở Fastify/HTTP server riêng
Gửi thông báo (email/in-app/mattermost)context.emitter.emitAction('notify.trigger', { template_id, recipients, variables }, ctx)Tự gọi SMTP/HTTP gửi mail
Tạo in-app notificationcontext.services.NotificationsServiceGhi thẳng odp_notifications
Kiểm tra quyền truy cập moduleawait context.validateAppAccess(accountability, module, action, scope, knex)Tự kiểm tra role/permission
Lấy schema collection/fieldawait context.getSchema()Đọc thẳng odp_collections/_fields
Chạy định kỳ (cron)Hook context.schedule('*/5 * * * *', fn)setInterval toàn cục
Chạy khi server start/stopHook context.init('server.start', fn)
Lưu dữ liệu riêng của extensionTạo collection (CollectionsService) rồi CRUD qua ItemsService(collection, {...})Nhồi vào odp_*; mặc định nhảy sang context.database
Query quá phức tạp / transaction lớn / bulk mà ItemsService không khamcontext.database (Knex) — chỉ khi thật cần, tự lo permissionDùng raw DB cho CRUD thông thường
Quản lý collection/field độngcontext.services.CollectionsService / FieldsService / RelationsServiceDDL Knex thủ công trên schema hệ thống
Versioning / duyệt nội dungcontext.services.VersionsService + workflow (event versions.submit)Tự xây versioning

Khi hook tự ghi lại đúng collection mình đang lắng nghe → truyền { emitEvents: false } để tránh vòng lặp vô hạn.


3. ODP đã có sẵn — DÙNG, không viết lại

Trước khi định "tự xử lý", kiểm tra danh sách này. Hầu hết nhu cầu đã có service tương ứng (đầy đủ trong Sitemap):

  • CRUD + query (filter _eq/_in/_and/_or, sort, aggregate, search, phân trang) → ItemsService / query engine. Đừng tự parse query hay viết SQL.
  • Permission field-level & row-level → tự áp dụng khi gọi service kèm accountability. Đừng tự lọc quyền.
  • Auth / session / SSO / sub-token / impersonationAuthService, SubTokenService, ImpersonationService. Đừng tự làm JWT/login.
  • Versioning, revisions, activity log, commentsVersionsService, RevisionsService, ActivityService, CommentsService.
  • Files + transform ảnh + storage (local/s3) + upload resumableFilesService, AssetsService. Đừng tự xử lý upload/resize.
  • Thông báo đa kênh + template + queue → event notify.trigger / NotificationsService. Đừng tự gửi mail.
  • Workflow duyệt nhiều bước → định nghĩa workflow + trigger qua versions.submit/items.*. Đừng tự xây state machine duyệt.
  • Settings, presets, translations/i18n, sharesSettingsService, PresetsService, TranslationsService, SharesService.
  • GraphQL, WebSocket realtimeGraphQLService, WebSocketService.
  • Lỗi chuẩn hoáthrow các error từ hệ thống (đừng tự định nghĩa format lỗi HTTP).

4. Cam kết ổn định vs Nội bộ (để update core không vỡ extension)

Core chỉ cam kết giữ ổn định bề mặt công khai. Phụ thuộc vào nội bộ = bản update core sau này sẽ làm vỡ extension của bạn.

✅ Được phép phụ thuộc (ổn định)

  • Import từ @odp/api/types: defineHook, defineEndpoint, và các type (ApiExtensionContext, HookConfig, EndpointConfig...).
  • ApiExtensionContext và các method công khai của context.services.*.
  • Sự kiện có tài liệu: items.create/update/delete/read (+ <collection>.items.*), items.sort, versions.submit, collections.*, fields.*, sub-tokens.create/revoke, notify.trigger, extensions.register, server.start/stop.
  • Manifest odp-extension (id, type, path/entries, permissions).
  • CLI: odp, odp-extension.

❌ KHÔNG được phụ thuộc (nội bộ — có thể đổi bất cứ lúc nào)

  • Import deep path: @odp/api/dist/..., service/api/src/..., hay bất cứ gì ngoài @odp/api/types.
  • Internals: AST query engine, security/*, middleware, DB dialects, permissions/* (hàm validate-*), cấu trúc bảng odp_*.
  • Method/field không có tài liệu của service.
  • Hành vi phụ (side-effect) không được cam kết.

Quy tắc vàng: nếu nó không nằm trong @odp/api/types hoặc ApiExtensionContext hoặc danh sách sự kiện trên → coi như nội bộ, đừng đụng.


5. DO / DON'T

✅ DO

  • Dùng context.services cho mọi nghiệp vụ; truyền accountability của request để giữ đúng quyền.
  • Dữ liệu riêng của extension → tạo collection rồi CRUD qua ItemsService (ưu tiên cao nhất — có permission/audit/parsing). Chỉ rơi xuống context.database (raw) khi ItemsService thật sự không kham (query quá phức tạp, transaction lớn, bulk) — và hiểu là bỏ qua permission/audit/parsing.
  • Thêm route qua endpoint; khai báo quyền ở manifest; kiểm bằng validateAppAccess.
  • Hook tự ghi lại → { emitEvents: false }.
  • Lỗi: throw error chuẩn của hệ thống với thông điệp rõ ràng.

❌ DON'T (gồm các "chiêu trò" cấm)

  • Làm lại tính năng đã có (CRUD, auth, permission, versioning, notify, files...) — xem §3.
  • Import nội bộ/deep path. Chỉ @odp/api/types.
  • Query/sửa thẳng bảng odp_* — luôn qua service.
  • Mặc định nhảy sang context.database cho CRUD — đó là lựa chọn cuối, không phải mặc định; CRUD (kể cả collection riêng) đi qua ItemsService trước.
  • Bypass permission: tránh accountability: null để "cho nhanh"; chỉ dùng null cho thao tác system có chủ đích và hiểu rõ hậu quả.
  • Monkey-patch / override service, Fastify instance, prototype, global; ghi đè logic core.
  • Hardcode SQL trùng logic core, hardcode URL/secret/credential.
  • Mở HTTP server/port riêng, hay đăng ký route ngoài cơ chế endpoint.
  • Phụ thuộc thứ tự side-effect hoặc chi tiết triển khai không cam kết.
  • "Vá" tạm bằng cách đọc/sửa state nội bộ của core để đạt mục tiêu trước mắt.

6. Chia sẻ service giữa các extension (Service Registry)

Khi extension A muốn cấp một service cho extension B / endpoint / hook khác dùng (vd metaService dựng lúc boot với database/getSchema), không nhét vào context.services (đó là barrel core, không phải chỗ của extension) và không import trực tiếp module của A vào B (coupling cứng — A vắng mặt là B vỡ). Dùng context.registry.

ts
import { createServiceRef } from '@odp/api/types';

// Contract — CHỈ interface + ref, không implementation. A và B cùng import.
// Package này tự do: private nội bộ org, hay public khi open source — tuỳ bạn.
export interface MetaService { resolve(id: string): Promise<Meta> }
export const metaServiceRef = createServiceRef<MetaService>('myorg.meta');

Provider (A) — đăng ký ở init 'extensions.register':

ts
export default defineHook((hook, ctx) => {
  hook.init('extensions.register', () => {
    ctx.registry.provide(metaServiceRef, new MetaServiceImpl(ctx.database, ctx.getSchema));
  });
});

Consumer (B) — đọc lazy (trong request handler / sau khi load xong); A có thể vắng:

ts
export default defineEndpoint((router, ctx) => {
  router.get('/x', async () => {
    const meta = ctx.registry.tryConsume(metaServiceRef);   // typed | undefined
    return meta ? meta.resolve('1') : fallback();           // A chưa cài → vẫn chạy
  });
});

Luật bắt buộc

  • Phụ thuộc CONTRACT, không phụ thuộc PROVIDER. B import metaServiceRef (contract), không bao giờ import A. A là một implementation, có thể vắng — tryConsume trả undefined, đó là trạng thái hợp lệ, B phải degrade.
  • provideinit('extensions.register') (phase đăng ký, await tuần tự trước khi seal). consume/tryConsume lazy — trong request handler, scheduled job, action handler, hoặc init sau extensions.register. Không consume ở top-level body của hook (chạy trước seal → bị chặn).
  • Không cần contract chung được (vd ext 3rd-party, contract private không chia sẻ): dùng string id + interface tự khai + has() — mất type-safety đổi lấy zero-coupling:
    ts
    interface MetaLike { resolve(id: string): Promise<{ title: string }> }
    if (ctx.registry.has('myorg.meta')) {
      const meta = ctx.registry.tryConsume<MetaLike>('myorg.meta')!;
    }

Self-diagnosing (dev)

Consume trước khi load xong (sai kỷ luật) → tryConsume trả undefined + log error kèm stack (1 lần/id); consume (hard) → throw với message phân biệt "consumed before registry sealed" vs "no provider". Lỗi này deterministic theo tập extension → dev gặp ngay lần boot đầu, fix là hết.

CầnDùngKhông làm
A cấp service cho Bctx.registry.provide(ref, impl)init('extensions.register')Nhét vào context.services; import module của A vào B
B dùng service của A (A optional)ctx.registry.tryConsume(ref) lazy, degrade nếu undefinedconsume top-level; giả định A luôn có
B bắt buộc cần Actx.registry.consume(ref) (throw nếu thiếu)nuốt lỗi rồi chạy tiếp với state sai
Không share được contractstring id + has() + interface tự khaiép import contract private của bên khác

7. Khi bí

Nếu nhu cầu không làm được trong ranh giới trên (vd cần một sự kiện core chưa phát, cần một khả năng core chưa expose): dừng lại, ghi rõ cái còn thiếu, và đề xuất bổ sung vào core (thêm sự kiện/method công khai) — thay vì luồn lách nội bộ. Mở rộng bề mặt công khai của core là việc của core, không phải của extension.

ODP Internal API Documentation