Compare commits

116 Commits

Author SHA1 Message Date
e29a936546 Bump Fabric.Backend.Center to Tessera bearer support
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:57:56 +01:00
bf309e590d chore(submodules): bump Fabric.OpenclawPlugin (#12 dynamic-subscription) 2026-06-01 08:58:47 +01:00
a47ca99a36 chore(submodules): bump Fabric.OpenclawPlugin (#11: resolveAgentRoute use binding.accountId)
# Conflicts:
#	Fabric.OpenclawPlugin
2026-05-31 20:33:31 +01:00
f3c472e612 chore: bump submodules — agent-driven dialectic (drop backend broadcast)
- Fabric.Backend.Center 9f72165 → 9e1909a
    drop serviceEndpoint (no consumer); keep purpose
  - Fabric.Backend.Guild b1f7467 → ca20df7
    drop system-key bypass + announce-only-system limit; keep purpose
  - Fabric.OpenclawPlugin 92945b7 → c330571
    drop serviceEndpoint from fabric-guild-list; new pre-bump features:
    fabric-guild-list + fabric-channel-set-purpose + purpose-aware
    create-*-channel + (callId, args) signature fix

Paired with Dialectic side: Dialectic.Backend rip announce.go +
broadcast ticker + topic.announce_* columns; Dialectic.OpenclawPlugin
rip propose_topic announce_* params. ClawSkills workflows rewritten to
agent-driven recruitment + HF on_call slot booking.
2026-05-23 23:55:37 +01:00
f8d787cf50 chore: bump Fabric.Frontend -> 607785a (prod logo brand asset)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:04:30 +01:00
5a810e8290 chore: bump Fabric.Backend.Center — OIDC unreachable-issuer hardening
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:47:29 +01:00
4b74659be1 chore: bump submodules — OIDC login
Fabric.Backend.Center -> 2a39496 (OIDC + CLI config-oidc)
Fabric.Frontend       -> 92d3b4d (OIDC UI + runtime env)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:44:57 +01:00
cb87488f0c chore: bump submodules — dm x-type + terminal restyle + brand override
Fabric.Backend.Guild  -> b1f7467 (dm x-type)
Fabric.OpenclawPlugin -> 92945b7 (dm no-wakeup delivery)
Fabric.Frontend       -> 4892af5 (restyle + dm UI + grouping + brand)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:18:28 +01:00
6d9b3660db chore: bump submodules for Critical security fixes (Guild C-2 config)
Fabric.Backend.Center  -> 6afb935 (C1/C2/C3 auth fixes)
Fabric.Backend.Guild   -> 7e944a0 (C-1/C-2 + sync-key CLI)
Fabric.OpenclawPlugin  -> ab12682 (commandsSyncKey required config field)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:47:40 +01:00
09acb5521c chore: slash-command full chain — bump Frontend/Plugin/Guild; TEST_POINTS §10
Backend.Guild registry + Plugin catalog sync + Frontend / autocomplete.
Verified: registry PUT/GET (401 unauth), 41 specs built+synced (incl.
dynamic choices), GET round-trip, frontend bundle+fetch wired.
Caveat: in-browser / panel interaction not automated; in-gateway auto
sync needs plugin reinstall + gateway restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:15:13 +01:00
385b2a0ac2 chore: bump Plugin (slash-command catalog sync)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:06:22 +01:00
9b86d53fe0 chore: bump Guild (slash-command registry)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:02:49 +01:00
1172b29588 chore: bump Plugin (fabric-channel tool)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:33:47 +01:00
0837625451 chore: bump Plugin (fabric-canvas tool; register env=AGENT_ID only)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:28:14 +01:00
8396b7a756 chore: bump Plugin (fabric-register script, not tool)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:12:49 +01:00
ac164077d3 docs: fix architecture diagram — plugin is a backend client, not linked to frontend
The old diagram placed the wakeup arrow under Fabric.Frontend, wrongly
implying a frontend<->plugin link. Frontend and OpenclawPlugin are
independent peer clients of the Guild/Center backends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:56:12 +01:00
ce4a437094 docs: rewrite root README; bump all submodules (README refresh)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:33 +01:00
add95357da chore: add Android (Capacitor wrapper of the frontend)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:39:09 +01:00
c6ec09ce0b chore: bump Frontend + Desktop (self-contained desktop bundle)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:13:50 +01:00
d367433306 chore: bump Desktop (deb launch/icon fix)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:47:39 +01:00
deeb89d54d chore: bump Frontend + Desktop (bolder icons, favicon cache-bust)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:21:40 +01:00
13c20508e7 chore: bump Frontend + Desktop (SVG favicon, deeper green)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:16:59 +01:00
21ed101505 chore: bump Frontend + Desktop (transparent tight-crop icons)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:06 +01:00
f3c265b4e3 chore: bump Frontend + Desktop (no-text app/favicon icon)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:07:46 +01:00
ac1b74518e chore: bump Frontend + Desktop (Fabric app/tray icons)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:05:06 +01:00
87f37122cb chore: bump Plugin (non-wakeup -> session history); TEST_POINTS P7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:42:37 +01:00
5a166d69f2 chore: bump Plugin (automatic source-reply delivery)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:02:48 +01:00
733bdfcd8d chore: bump Plugin (guild token refresh)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:03:37 +01:00
a9f2c17f84 docs: P14 — agent file receipt proven live (read tool on delivered path)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:01:30 +01:00
5ab0eaf038 chore: bump Plugin (MediaPaths SSRF fix); P14 verified-extent notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:50:40 +01:00
edb2af5cbc chore: bump Guild/Frontend/Plugin (files + canvas); docs
File upload (100MB default, 1-week retention), per-channel pinned
canvas (single active, sharer-only update), and plugin file delivery
to agents. TEST_POINTS §8 + P14 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:17:36 +01:00
5a8bef520d chore: bump Frontend (right-click context menus)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:36:02 +01:00
77be66e26b chore: bump Guild+Frontend (bypass-list); add docs/TEST_POINTS.md
bypass-list feature for discuss/work channels (order/bypass disjoint
partition, mention nesting cap, mid-rotation move-to-bypass) + the
stack-wide test-point reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:35 +01:00
ca051a5f7d chore: bump Center+Guild submodules (ESM migration)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:36 +01:00
efe6f0debf chore: bump Fabric.OpenclawPlugin (binding docs)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:35:50 +01:00
3749d9de06 chore: bump Fabric.OpenclawPlugin (no message splitting)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:30:26 +01:00
bf70b6636c chore: bump Fabric.OpenclawPlugin (working round-trip v1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:24:36 +01:00
c7977b398e chore: bump Fabric.OpenclawPlugin (loadable channel plugin v1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:54:33 +01:00
dcd35db310 chore: bump Fabric.OpenclawPlugin (installer)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:30:42 +01:00
455da9d02f chore: bump Fabric.OpenclawPlugin (channel plugin Phase 1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:13:27 +01:00
7457cecd0a chore: bump submodules (agent API-key auth + closed channels)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:53:36 +01:00
3c74d8d042 chore: bump Fabric.Frontend (mention chips)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:52:44 +01:00
ed8775f3a0 chore: bump submodules (name-mention translation + md rendering)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:47:03 +01:00
30587bd8a8 chore: bump Guild+Frontend (mentions + public members section)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:27:37 +01:00
48776a3355 chore: bump Guild+Frontend (split members sidebar)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:00:25 +01:00
44d06e9368 chore: bump Guild+Frontend submodules (wakeup/turn engine, join-leave, dev mode)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:21 +01:00
5729d80ac2 chore: bump Guild+Frontend submodules (channel x_type)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:35:37 +01:00
5bf0d93938 chore: bump Fabric.Frontend (Discord-style dark redesign)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:26:11 +01:00
1c70cab082 chore: bump Fabric.Frontend (fix stale guild-token 401 on refresh)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:14:11 +01:00
29f7086b66 chore: bump submodules - user display name + channel membership/public
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:09:50 +01:00
d1c9b8c8c5 chore: bump Fabric.Frontend submodule (channel-create + error modal fix)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:52:34 +01:00
f97dd79e00 chore: bump Center/Guild/Frontend submodules to latest main
- Fabric.Backend.Center 81dfc22 -> 2792f78 (local CLI, CORS, guild join/members)
- Fabric.Backend.Guild  fdb661f -> 78d2179 (CORS, members API, channel validation)
- Fabric.Frontend       4724678 -> 40540ab (English UI, layout redesign, guild join flow)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:38:43 +01:00
nav
6ee4afa1e1 chore(script): register guild node via backend-center CLI 2026-05-14 14:44:04 +00:00
nav
99b13b99fc chore(env): standardize prefixed env names across center and guild 2026-05-13 12:58:42 +00:00
nav
944c77e37b docs: add minimal deploy auth flow guide 2026-05-13 08:54:02 +00:00
nav
bbff1c9af9 chore: bump guild submodule for startup env validation 2026-05-13 08:52:30 +00:00
nav
9d28a465a5 feat(admin): add local center guild registration helper script 2026-05-13 08:47:50 +00:00
nav
ceec514ead chore(deploy): remove center shared secret from env and compose 2026-05-13 08:41:59 +00:00
nav
714482db16 chore(deploy): use center api key for guild-center calls 2026-05-13 08:36:31 +00:00
nav
574e0e74ef chore(auth): wire center api key requirements across services 2026-05-13 08:18:16 +00:00
nav
f183e50413 feat(auth-flow): center-issued guild tokens with guild introspection 2026-05-13 08:00:48 +00:00
nav
f6b74335dc fix(dev): add missing center/guild env vars in docker compose 2026-05-13 07:22:04 +00:00
nav
3bd11fc5b3 chore: bump Fabric.Desktop submodule for maintainer email 2026-05-13 07:17:08 +00:00
nav
358064b810 chore(submodule): convert backend center/guild to dedicated repos
Some checks failed
backend-ci / verify (Fabric.Backend.Center) (push) Has been cancelled
backend-ci / verify (Fabric.Backend.Guild) (push) Has been cancelled
2026-05-13 07:06:58 +00:00
nav
38eb704df3 docs(plan): close non-test MVP items and add DoD 2026-05-13 06:58:22 +00:00
nav
c35a1e4ec4 chore(plan): complete desktop 2.3/2.4 and bump desktop submodule 2026-05-13 06:57:31 +00:00
nav
001a82fb9d chore(plan): complete desktop shell security baseline and local config tasks 2026-05-12 16:13:11 +00:00
nav
9eb61d9b73 chore(plan): complete frontend observability and stability track 2026-05-12 16:10:35 +00:00
nav
c5c6ad347b chore(plan): complete frontend stability states and lint-build gate 2026-05-12 16:08:22 +00:00
nav
7a216628d5 chore(plan): complete frontend realtime tasks
Some checks failed
backend-ci / verify (Fabric.Backend.Center) (push) Has been cancelled
backend-ci / verify (Fabric.Backend.Guild) (push) Has been cancelled
2026-05-12 16:04:31 +00:00
nav
7f73607c32 chore(plan): complete frontend message flow including gap hint 2026-05-12 16:00:43 +00:00
nav
f81f9419e0 chore(plan): complete frontend message flow tasks except gap hint 2026-05-12 15:59:18 +00:00
nav
a0be5d6b36 chore(plan): complete frontend guild-channel browser tasks 2026-05-12 15:26:21 +00:00
nav
9d2a330f69 feat(guild): add guild and channel list APIs for frontend browser 2026-05-12 15:25:26 +00:00
nav
271e712804 chore(plan): complete frontend auth and session tasks 2026-05-12 15:09:12 +00:00
nav
1c386e0a80 chore(plan): complete frontend app-shell client wrapper tasks 2026-05-12 13:46:24 +00:00
nav
34442663a3 chore(plan): add frontend-desktop todo and complete frontend routing skeleton step 2026-05-12 13:14:01 +00:00
nav
5a2462a49e chore: bump frontend and desktop submodule refs 2026-05-12 13:10:00 +00:00
nav
86ec39f7d2 test(guild): add concurrent message write verification and fix duplicate messageId index 2026-05-12 12:51:00 +00:00
nav
71ac0f91c6 docs(todo): align MVP DoD with unified api key model and current completion 2026-05-12 12:47:28 +00:00
nav
0f7b99c687 docs(ops): add backup and restore runbook 2026-05-12 12:44:08 +00:00
nav
b7d66f334a feat(observability): add in-process metrics endpoint for qps latency and error-rate 2026-05-12 12:39:20 +00:00
nav
b7c9e34738 feat(observability): add structured request logs with request-id middleware 2026-05-12 12:35:23 +00:00
nav
07d8b20f57 feat(db): add TypeORM migration workflow for center and guild 2026-05-12 12:32:44 +00:00
nav
1b568757cb ops: add production compose with DB_SYNC disabled and env template 2026-05-12 12:30:04 +00:00
nav
7cf0c50921 test(contract): add center-guild registration contract integration test 2026-05-12 12:26:03 +00:00
nav
bccd942898 feat(guild-realtime): add presence and typing websocket events 2026-05-12 12:22:36 +00:00
nav
33d101af22 feat(guild-realtime): broadcast message lifecycle events over websocket 2026-05-12 12:18:01 +00:00
nav
01090273c6 feat(guild-realtime): add websocket gateway with api-key auth and channel rooms 2026-05-12 12:13:19 +00:00
nav
5b28ad52bb test: stabilize vitest config and integration test environment
Some checks failed
backend-ci / verify (Fabric.Backend.Center) (push) Has been cancelled
backend-ci / verify (Fabric.Backend.Guild) (push) Has been cancelled
2026-05-12 12:09:49 +00:00
nav
8534c530c8 test(integration): add lightweight MySQL-backed API smoke tests for center and guild 2026-05-12 12:04:33 +00:00
nav
41a4172267 test(unit): add lightweight vitest coverage for auth duration and seq pagination utils 2026-05-12 11:53:46 +00:00
nav
ec796ae609 chore(quality): mark lint and build gates green for both services 2026-05-12 11:42:00 +00:00
nav
0731778bd3 feat(docs): add Swagger/OpenAPI endpoints for center and guild services 2026-05-12 11:41:30 +00:00
nav
b014767324 feat(guild-auth): enforce unified x-api-key auth for inbound APIs 2026-05-12 11:32:25 +00:00
nav
11aa538793 docs(todo): replace bot token auth item with unified api key auth 2026-05-12 11:30:28 +00:00
nav
24cbee3135 feat(guild-events): add outbound retry queue with exponential backoff 2026-05-12 11:28:21 +00:00
nav
7e458ad6d3 feat(guild-events): add HMAC-signed webhook delivery and replay guards 2026-05-12 11:26:21 +00:00
nav
37ec670280 feat(guild-events): add webhook event envelope and message lifecycle emits 2026-05-12 11:24:43 +00:00
nav
8ca5d68ba4 feat(protocol): enforce X-Fabric-Version negotiation on node registration 2026-05-12 11:22:18 +00:00
nav
3795aea2cb docs(protocol): define unified error codes and retry policy v1 2026-05-12 11:18:00 +00:00
nav
676e838697 docs(protocol): document center-guild HMAC registration handshake v1 2026-05-12 11:12:48 +00:00
nav
ab01a83a90 feat(center-protocol): enforce HMAC auth for node registration 2026-05-12 11:11:29 +00:00
nav
670762aa7a feat(guild-messaging): add idempotency-key support for write endpoints 2026-05-12 10:40:00 +00:00
nav
2ec50f3234 feat(guild-messaging): add gap-detection metadata for seq backfill responses 2026-05-12 10:37:27 +00:00
nav
fa5d0d31b2 feat(guild-messaging): switch seq allocation to DB transaction with row lock 2026-05-12 10:35:41 +00:00
nav
4b4755b33b feat(guild-messaging): add seq-range pagination with limit for message listing 2026-05-12 10:32:25 +00:00
nav
c08fa4756b feat(guild-messaging): implement soft delete strategy for messages 2026-05-12 10:30:51 +00:00
nav
325e13ee13 feat(guild-messaging): add simplified message edit window policy 2026-05-12 10:29:29 +00:00
nav
d3fdc3dd1e feat(guild-messaging): support message metadata for reply mentions and attachments 2026-05-12 10:28:02 +00:00
nav
ceaece754e feat(guild-model): add practical indexes for channel/dm/member queries 2026-05-12 10:21:48 +00:00
nav
e53c943991 feat(guild-model): add member and role base entities 2026-05-12 09:02:21 +00:00
nav
46f138328e feat(guild-model): complete guild/channel/dm entities 2026-05-12 09:01:24 +00:00
nav
2e2e217b5f feat(center): enhance health check with DB readiness 2026-05-12 08:58:44 +00:00
nav
7270256587 feat(center): add audit logs for auth and node operations 2026-05-12 08:57:34 +00:00
nav
7f68a09486 feat(center-nodes): add node heartbeat endpoint
Some checks failed
backend-ci / verify (Fabric.Backend.Center) (push) Has been cancelled
backend-ci / verify (Fabric.Backend.Guild) (push) Has been cancelled
2026-05-12 08:54:17 +00:00
67 changed files with 1002 additions and 9691 deletions

23
.env.prod.example Normal file
View File

@@ -0,0 +1,23 @@
# ---------- MySQL Center ----------
MYSQL_CENTER_ROOT_PASSWORD=change-me-center-root
MYSQL_CENTER_DATABASE=fabric_center
MYSQL_CENTER_USER=fabric
MYSQL_CENTER_PASSWORD=change-me-center-db
# ---------- MySQL Guild ----------
MYSQL_GUILD_ROOT_PASSWORD=change-me-guild-root
MYSQL_GUILD_DATABASE=fabric_guild
MYSQL_GUILD_USER=fabric
MYSQL_GUILD_PASSWORD=change-me-guild-db
# ---------- Center secrets ----------
CENTER_SHARED_SECRET=change-me-center-shared-secret
JWT_ACCESS_SECRET=change-me-jwt-access-secret
JWT_REFRESH_SECRET=change-me-jwt-refresh-secret
# ---------- Guild auth ----------
FABRIC_API_KEY=change-me-fabric-api-key
# ---------- Optional webhook ----------
FABRIC_WEBHOOK_URL=
FABRIC_WEBHOOK_SECRET=

1
Fabric.Backend.Center Submodule

Submodule Fabric.Backend.Center added at 8412eb6b3e

View File

@@ -1,20 +0,0 @@
# Server
PORT=7001
# MySQL
DB_HOST=mysql-center
DB_PORT=3306
DB_USER=fabric
DB_PASSWORD=fabric
DB_NAME=fabric_center
DB_SYNC=true
DB_LOGGING=false
# Auth (to be used in auth module)
JWT_ACCESS_SECRET=change-me-access
JWT_REFRESH_SECRET=change-me-refresh
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=30d
# Center <-> Guild handshake
CENTER_SHARED_SECRET=change-me-center-secret

View File

@@ -1,2 +0,0 @@
node_modules/
dist/

View File

@@ -1,19 +0,0 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 7001
CMD ["node", "dist/main.js"]

View File

@@ -1,14 +0,0 @@
# Fabric.Backend.Center
Identity Hub service for Fabric.
## Scope (MVP)
- User register/login
- Session/token management
- Guild Node registration + shared-secret handshake
- Center-level audit logs
## Next
- API skeleton (NestJS)
- Auth module
- Guild node registry module

View File

@@ -1,28 +0,0 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
export default [
{
ignores: ['dist/**', 'node_modules/**'],
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
console: 'readonly',
process: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
{
"name": "fabric-backend-center",
"version": "0.1.0",
"private": true,
"description": "Fabric Identity Hub (Center service)",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'"
},
"dependencies": {
"@nestjs/common": "^10.4.8",
"@nestjs/core": "^10.4.8",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/typeorm": "^11.0.1",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.22.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.29"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { buildTypeOrmConfig } from './database.config';
import { HealthController } from './common/health.controller';
import { AuthModule } from './auth/auth.module';
import { NodesModule } from './nodes/nodes.module';
@Module({
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), AuthModule, NodesModule],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -1,31 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto.register.dto';
import { LoginDto } from './dto.login.dto';
import { RefreshDto } from './dto.refresh.dto';
import { LogoutDto } from './dto.logout.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() body: RegisterDto) {
return this.authService.register(body);
}
@Post('login')
login(@Body() body: LoginDto) {
return this.authService.login(body);
}
@Post('refresh')
refresh(@Body() body: RefreshDto) {
return this.authService.refresh(body.refreshToken);
}
@Post('logout')
logout(@Body() body: LogoutDto) {
return this.authService.logout(body.refreshToken);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from '../entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}

View File

@@ -1,140 +0,0 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { User } from '../entities/user.entity';
import { RegisterDto } from './dto.register.dto';
import { LoginDto } from './dto.login.dto';
function parseDurationToSeconds(input: string, fallbackSeconds: number): number {
const raw = input.trim();
if (/^\d+$/.test(raw)) return Number(raw);
const m = raw.match(/^(\d+)([smhd])$/i);
if (!m) return fallbackSeconds;
const value = Number(m[1]);
const unit = m[2].toLowerCase();
if (unit === 's') return value;
if (unit === 'm') return value * 60;
if (unit === 'h') return value * 3600;
if (unit === 'd') return value * 86400;
return fallbackSeconds;
}
function signAccessToken(userId: string, email: string): string {
const secret = process.env.JWT_ACCESS_SECRET as string;
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
return jwt.sign({ sub: userId, email }, secret, { expiresIn });
}
function signRefreshToken(userId: string, email: string): string {
const secret = process.env.JWT_REFRESH_SECRET as string;
const expiresIn = parseDurationToSeconds(process.env.JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000);
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async register(input: RegisterDto) {
const exists = await this.userRepo.findOne({ where: { email: input.email } });
if (exists) {
throw new ConflictException('email already exists');
}
const passwordHash = await bcrypt.hash(input.password, 10);
const user = this.userRepo.create({
email: input.email,
passwordHash,
refreshTokenHash: null,
});
const saved = await this.userRepo.save(user);
return {
id: saved.id,
email: saved.email,
createdAt: saved.createdAt,
};
}
async login(input: LoginDto) {
const user = await this.userRepo.findOne({ where: { email: input.email } });
if (!user) throw new UnauthorizedException('invalid credentials');
const ok = await bcrypt.compare(input.password, user.passwordHash);
if (!ok) throw new UnauthorizedException('invalid credentials');
const accessToken = signAccessToken(user.id, user.email);
const refreshToken = signRefreshToken(user.id, user.email);
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
await this.userRepo.save(user);
return {
accessToken,
refreshToken,
tokenType: 'Bearer',
user: {
id: user.id,
email: user.email,
},
};
}
async refresh(refreshToken: string) {
let payload: jwt.JwtPayload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
} catch {
throw new UnauthorizedException('invalid refresh token');
}
const userId = String(payload.sub ?? '');
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user || !user.refreshTokenHash) {
throw new UnauthorizedException('invalid refresh token');
}
const tokenOk = await bcrypt.compare(refreshToken, user.refreshTokenHash);
if (!tokenOk) throw new UnauthorizedException('invalid refresh token');
const newAccessToken = signAccessToken(user.id, user.email);
const newRefreshToken = signRefreshToken(user.id, user.email);
user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
await this.userRepo.save(user);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
tokenType: 'Bearer',
};
}
async logout(refreshToken: string) {
let payload: jwt.JwtPayload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
} catch {
return { status: 'ok' };
}
const userId = String(payload.sub ?? '');
if (!userId) return { status: 'ok' };
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) return { status: 'ok' };
user.refreshTokenHash = null;
await this.userRepo.save(user);
return { status: 'ok' };
}
}

View File

@@ -1,10 +0,0 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -1,7 +0,0 @@
import { IsString, MinLength } from 'class-validator';
export class LogoutDto {
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@@ -1,7 +0,0 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshDto {
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@@ -1,10 +0,0 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -1,9 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('healthz')
export class HealthController {
@Get()
get() {
return { ok: true, service: 'center' };
}
}

View File

@@ -1,15 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { GuildNode } from './entities/guild-node.entity';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql',
host: process.env.DB_HOST ?? 'mysql-center',
port: Number(process.env.DB_PORT ?? 3306),
username: process.env.DB_USER ?? 'fabric',
password: process.env.DB_PASSWORD ?? 'fabric',
database: process.env.DB_NAME ?? 'fabric_center',
entities: [User, GuildNode],
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
});

View File

@@ -1,26 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guild_nodes')
export class GuildNode {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ unique: true })
nodeId!: string;
@Column()
name!: string;
@Column()
endpoint!: string;
@Column({
type: 'enum',
enum: ['active', 'offline', 'revoked'],
default: 'active',
})
status!: 'active' | 'offline' | 'revoked';
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,19 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ unique: true })
email!: string;
@Column()
passwordHash!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
refreshTokenHash!: string | null;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,42 +0,0 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value || value.trim() === '') {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
function validateEnv(): void {
requireEnv('DB_HOST');
requireEnv('DB_PORT');
requireEnv('DB_USER');
requireEnv('DB_PASSWORD');
requireEnv('DB_NAME');
requireEnv('CENTER_SHARED_SECRET');
requireEnv('JWT_ACCESS_SECRET');
requireEnv('JWT_REFRESH_SECRET');
}
async function bootstrap() {
validateEnv();
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const port = process.env.PORT ? Number(process.env.PORT) : 7001;
await app.listen(port);
console.log(`Fabric.Backend.Center listening on :${port}`);
}
void bootstrap();

View File

@@ -1,18 +0,0 @@
import { IsString, IsUrl, MinLength } from 'class-validator';
export class RegisterNodeDto {
@IsString()
@MinLength(3)
nodeId!: string;
@IsString()
@MinLength(2)
name!: string;
@IsUrl({ require_tld: false })
endpoint!: string;
@IsString()
@MinLength(8)
sharedSecret!: string;
}

View File

@@ -1,6 +0,0 @@
import { IsIn } from 'class-validator';
export class UpdateNodeStatusDto {
@IsIn(['active', 'offline', 'revoked'])
status!: 'active' | 'offline' | 'revoked';
}

View File

@@ -1,111 +0,0 @@
import {
Body,
ConflictException,
Controller,
DefaultValuePipe,
ForbiddenException,
Get,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GuildNode } from '../entities/guild-node.entity';
import { RegisterNodeDto } from './dto.register-node.dto';
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
@Controller('nodes')
export class NodesController {
constructor(
@InjectRepository(GuildNode)
private readonly nodeRepo: Repository<GuildNode>,
) {}
@Post('register')
async register(@Body() body: RegisterNodeDto) {
if (body.sharedSecret !== process.env.CENTER_SHARED_SECRET) {
throw new ForbiddenException('invalid shared secret');
}
const existedByNodeId = await this.nodeRepo.findOne({
where: { nodeId: body.nodeId },
});
if (existedByNodeId) {
throw new ConflictException('nodeId already exists');
}
const existedByEndpoint = await this.nodeRepo.findOne({
where: { endpoint: body.endpoint },
});
if (existedByEndpoint) {
throw new ConflictException('endpoint already exists');
}
const node = this.nodeRepo.create({
nodeId: body.nodeId,
name: body.name,
endpoint: body.endpoint,
status: 'active',
});
const saved = await this.nodeRepo.save(node);
return {
status: 'accepted',
node: {
id: saved.id,
nodeId: saved.nodeId,
name: saved.name,
endpoint: saved.endpoint,
status: saved.status,
},
};
}
@Patch(':nodeId/status')
async updateStatus(
@Param('nodeId') nodeId: string,
@Body() body: UpdateNodeStatusDto,
) {
const node = await this.nodeRepo.findOne({ where: { nodeId } });
if (!node) {
throw new NotFoundException('node not found');
}
node.status = body.status;
const saved = await this.nodeRepo.save(node);
return {
id: saved.id,
nodeId: saved.nodeId,
name: saved.name,
endpoint: saved.endpoint,
status: saved.status,
};
}
@Get()
async list(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
) {
const safePage = page < 1 ? 1 : page;
const safePageSize = pageSize < 1 ? 20 : Math.min(pageSize, 100);
const [items, total] = await this.nodeRepo.findAndCount({
order: { createdAt: 'DESC' },
skip: (safePage - 1) * safePageSize,
take: safePageSize,
});
return {
items,
page: safePage,
pageSize: safePageSize,
total,
totalPages: Math.max(1, Math.ceil(total / safePageSize)),
};
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NodesController } from './nodes.controller';
import { GuildNode } from '../entities/guild-node.entity';
@Module({
imports: [TypeOrmModule.forFeature([GuildNode])],
controllers: [NodesController],
})
export class NodesModule {}

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

1
Fabric.Backend.Guild Submodule

Submodule Fabric.Backend.Guild added at ca20df7618

View File

@@ -1,20 +0,0 @@
# Server
PORT=7002
# MySQL
DB_HOST=mysql-guild
DB_PORT=3306
DB_USER=fabric
DB_PASSWORD=fabric
DB_NAME=fabric_guild
DB_SYNC=true
DB_LOGGING=false
# Guild identity
GUILD_NODE_ID=guild-node-1
GUILD_NODE_NAME=Guild Node 1
GUILD_PUBLIC_ENDPOINT=http://localhost:7002
# Center handshake
CENTER_BASE_URL=http://backend-center:7001
CENTER_SHARED_SECRET=change-me-center-secret

View File

@@ -1,2 +0,0 @@
node_modules/
dist/

View File

@@ -1,19 +0,0 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 7002
CMD ["node", "dist/main.js"]

View File

@@ -1,15 +0,0 @@
# Fabric.Backend.Guild
Guild Node service for Fabric.
## Scope (MVP)
- Workspace/Guild/Channel/DM
- Message create/edit/delete/reply/@mention
- Per-channel/DM seq ordering + gap backfill API
- Webhook/Bot integration surface
- Guild-level audit logs
## Next
- API skeleton (NestJS)
- Chat domain models
- Seq allocator and range query endpoints

View File

@@ -1,28 +0,0 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
export default [
{
ignores: ['dist/**', 'node_modules/**'],
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
console: 'readonly',
process: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"name": "fabric-backend-guild",
"version": "0.1.0",
"private": true,
"description": "Fabric Guild Node service",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'"
},
"dependencies": {
"@nestjs/common": "^10.4.8",
"@nestjs/core": "^10.4.8",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/typeorm": "^11.0.1",
"mysql2": "^3.22.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.29"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.10.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { buildTypeOrmConfig } from './database.config';
import { HealthController } from './common/health.controller';
import { GuildsModule } from './guilds/guilds.module';
import { ChannelsModule } from './channels/channels.module';
import { MessagingModule } from './messaging/messaging.module';
@Module({
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), GuildsModule, ChannelsModule, MessagingModule],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -1,9 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
@Controller('channels')
export class ChannelsController {
@Post()
create(@Body() body: Record<string, unknown>) {
return { status: 'todo', action: 'create-channel', received: body };
}
}

View File

@@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { ChannelsController } from './channels.controller';
@Module({
controllers: [ChannelsController],
})
export class ChannelsModule {}

View File

@@ -1,9 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('healthz')
export class HealthController {
@Get()
get() {
return { ok: true, service: 'guild' };
}
}

View File

@@ -1,16 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Guild } from './entities/guild.entity';
import { Channel } from './entities/channel.entity';
import { Message } from './entities/message.entity';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql',
host: process.env.DB_HOST ?? 'mysql-guild',
port: Number(process.env.DB_PORT ?? 3306),
username: process.env.DB_USER ?? 'fabric',
password: process.env.DB_PASSWORD ?? 'fabric',
database: process.env.DB_NAME ?? 'fabric_guild',
entities: [Guild, Channel, Message],
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
});

View File

@@ -1,20 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('channels')
export class Channel {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
guildId!: string;
@Column()
name!: string;
@Index()
@Column({ default: 0 })
lastSeq!: number;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,13 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guilds')
export class Guild {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
name!: string;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,21 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('messages')
@Index(['channelId', 'seq'], { unique: true })
export class Message {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column()
channelId!: string;
@Column()
seq!: number;
@Column({ type: 'text' })
content!: string;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,9 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
@Controller('guilds')
export class GuildsController {
@Post()
create(@Body() body: Record<string, unknown>) {
return { status: 'todo', action: 'create-guild', received: body };
}
}

View File

@@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { GuildsController } from './guilds.controller';
@Module({
controllers: [GuildsController],
})
export class GuildsModule {}

View File

@@ -1,13 +0,0 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const port = process.env.PORT ? Number(process.env.PORT) : 7002;
await app.listen(port);
console.log(`Fabric.Backend.Guild listening on :${port}`);
}
void bootstrap();

View File

@@ -1,62 +0,0 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
type Message = {
messageId: string;
seq: number;
content: string;
};
@Controller('channels/:id/messages')
export class MessagingController {
private seqByChannel = new Map<string, number>();
private messagesByChannel = new Map<string, Message[]>();
@Post()
create(@Param('id') channelId: string, @Body() body: { content?: string; messageId?: string }) {
const next = (this.seqByChannel.get(channelId) ?? 0) + 1;
this.seqByChannel.set(channelId, next);
const message: Message = {
messageId: body.messageId ?? `m-${channelId}-${next}`,
seq: next,
content: body.content ?? '',
};
const arr = this.messagesByChannel.get(channelId) ?? [];
arr.push(message);
this.messagesByChannel.set(channelId, arr);
return message;
}
@Patch(':messageId')
edit(@Param('id') channelId: string, @Param('messageId') messageId: string, @Body() body: { content?: string }) {
const arr = this.messagesByChannel.get(channelId) ?? [];
const item = arr.find((m) => m.messageId === messageId);
if (!item) return { status: 'not_found' };
item.content = body.content ?? item.content;
return item;
}
@Delete(':messageId')
remove(@Param('id') channelId: string, @Param('messageId') messageId: string) {
const arr = this.messagesByChannel.get(channelId) ?? [];
const next = arr.filter((m) => m.messageId !== messageId);
this.messagesByChannel.set(channelId, next);
return { status: 'deleted', messageId };
}
@Get()
listBySeq(
@Param('id') channelId: string,
@Query('seq_from') seqFrom?: string,
@Query('seq_to') seqTo?: string,
) {
const from = seqFrom ? Number(seqFrom) : 1;
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
const arr = this.messagesByChannel.get(channelId) ?? [];
return {
items: arr.filter((m) => m.seq >= from && m.seq <= to),
};
}
}

View File

@@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { MessagingController } from './messaging.controller';
@Module({
controllers: [MessagingController],
})
export class MessagingModule {}

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

106
README.md
View File

@@ -1 +1,107 @@
# Fabric
A self-hosted, Discord-like team chat platform with first-class **AI-agent**
participation. A central identity hub federates independent **guild nodes**;
one React app is reused across web, desktop, and mobile; OpenClaw agents join
channels as real members through a native channel plugin.
## Architecture
```
┌──────────────────────┐
│ Fabric.Backend │ identity hub (NestJS,
│ .Center :7001 │ :7001) — users · JWT ·
└─────────┬────────────┘ agent API keys · node
│ registry · name resolve
registers / │
introspects ┌─────┴───────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Fabric.Backend │ │ Fabric.Backend │ guild nodes
│ .Guild :7002 │ │ .Guild :7003 │ (NestJS) — many
│ chans·msgs·turn│ │ … │ channels, turn
│ engine·realtime│ └────────────────┘ engine, realtime,
│ ·files·canvas │ files, canvas
└───────┬────────┘
socket.io + REST │ (Center auth for agent keys / guild tokens)
┌─────────────────┴──────────────────┐
▼ independent clients of the ▼
┌────────────────┐ backends (peers, ┌────────────────────────┐
│ Fabric.Frontend│ not linked) │ Fabric.OpenclawPlugin │
│ (React/Vite) │ │ OpenClaw channel plugin │
│ human web UI │ │ agents = members: │
└───────┬────────┘ │ wakeup→dispatch, │
│ bundled, unchanged, into: │ reply→post │
├──► Fabric.Desktop (Electron) └────────────────────────┘
└──► Fabric.Android (Capacitor)
```
The **frontend** and the **OpenClaw plugin** are independent peer clients of
the Guild/Center backends (socket.io + REST). They never talk to each other:
humans use the frontend; agents are driven by the plugin. Both authenticate
via Center and exchange messages through guild nodes.
## Repository layout (git submodules)
| Submodule | Role |
|---|---|
| `Fabric.Backend.Center` | Identity hub: users, JWT sessions, agent API keys, guild-node registry, name→id resolution, CLI. |
| `Fabric.Backend.Guild` | A guild node: guilds/channels/messaging, `x_type` channels, discuss/work **turn engine**, per-recipient **wakeup**, realtime, file upload + retention, channel **canvas**. |
| `Fabric.Frontend` | The React SPA (the actual UI). Served on the web, and bundled into Desktop & Android. |
| `Fabric.Desktop` | Electron shell that **bundles** the frontend (self-contained). |
| `Fabric.Android` | Capacitor shell that **bundles** the frontend. |
| `Fabric.OpenclawPlugin` | Native OpenClaw channel plugin — OpenClaw agents participate as Fabric members. |
## Key concepts
- **Federation.** Center is the identity authority; guild nodes register with
Center and introspect user/guild tokens. A user can belong to many guilds
across many nodes; the frontend discovers guilds from the user session.
- **Channel `x_type`.** Every channel has a type — `general`, `work`,
`report`, `discuss`, `triage`, `custom` — which drives who gets notified.
- **`wakeup` metadata.** On `message.created`, each recipient gets a per-push
`wakeup` boolean. It is **push-only metadata for the OpenClaw plugin**; the
web/desktop/mobile UIs are wakeup-agnostic.
- **discuss/work turn engine** (server-side, in `Fabric.Backend.Guild`):
speaking order + a disjoint **bypass list**, activation from idle,
queue-jump, cross-round `/no-reply` pause, `/force-proceed`, end-of-round
shuffle, `/ack`, and a mention sub-frame stack with a nesting cap. Only the
woken speaker acts; everyone else just receives context.
- **Agents = accounts.** Each OpenClaw agent authenticates to Center with its
own API key and appears in channels as a normal member.
- **ES modules everywhere.** Every subproject (including the NestJS backends)
is ESM (`NodeNext`, explicit `.js` relative imports, CJS deps default-imported).
## Local stack
`docker-compose.local.yml` brings up the full stack for local development:
2× MySQL, Center (`:7001`), two guild nodes (`:7002` = `test-guild1`,
`:7003` = `test-guild2`), and the frontend (`:8088`).
```bash
docker compose -f docker-compose.local.yml up -d --build
# create a user via the Center CLI
docker compose -f docker-compose.local.yml exec backend-center \
node dist/cli.js user create --email you@t.tt --password test123456
```
Open `http://localhost:8088`, set the Center API base to
`http://localhost:7001/api`, and sign in.
> Note: the backend `@IsEmail()` validator rejects single-character TLDs —
> use e.g. `you@t.tt`, not `you@tt.t`.
## Testing
`docs/TEST_POINTS.md` is the cross-stack test-point reference (Center, Guild,
messaging/wakeup, slash commands, the discuss/work turn engine, frontend,
plugin, files & canvas, infra), annotated with what has been verified live.
## Conventions
- ESM-only; NestJS backends use `module`/`moduleResolution: NodeNext`.
- Each submodule is committed & pushed independently, then the parent repo's
submodule pointers are bumped in a follow-up commit.
- HTTPS git credentials are stored repo-locally under `.git/` and are never
committed.

91
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,91 @@
services:
mysql-center:
image: mysql:8.4
container_name: fabric-mysql-center
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_CENTER_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_CENTER_DATABASE:-fabric_center}
MYSQL_USER: ${MYSQL_CENTER_USER:-fabric}
MYSQL_PASSWORD: ${MYSQL_CENTER_PASSWORD}
ports:
- "3307:3306"
volumes:
- mysql_center_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_CENTER_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 12
mysql-guild:
image: mysql:8.4
container_name: fabric-mysql-guild
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_GUILD_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_GUILD_DATABASE:-fabric_guild}
MYSQL_USER: ${MYSQL_GUILD_USER:-fabric}
MYSQL_PASSWORD: ${MYSQL_GUILD_PASSWORD}
ports:
- "3308:3306"
volumes:
- mysql_guild_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_GUILD_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 12
backend-center:
build:
context: ./Fabric.Backend.Center
dockerfile: Dockerfile
container_name: fabric-backend-center
restart: unless-stopped
depends_on:
mysql-center:
condition: service_healthy
environment:
FABRIC_BACKEND_CENTER_PORT: 7001
FABRIC_BACKEND_CENTER_DB_HOST: mysql-center
FABRIC_BACKEND_CENTER_DB_PORT: 3306
FABRIC_BACKEND_CENTER_DB_USER: ${MYSQL_CENTER_USER:-fabric}
FABRIC_BACKEND_CENTER_DB_PASSWORD: ${MYSQL_CENTER_PASSWORD}
FABRIC_BACKEND_CENTER_DB_NAME: ${MYSQL_CENTER_DATABASE:-fabric_center}
FABRIC_BACKEND_CENTER_DB_SYNC: "false"
FABRIC_BACKEND_CENTER_DB_LOGGING: "false"
FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET: ${FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET}
FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET: ${FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET}
ports:
- "7001:7001"
backend-guild:
build:
context: ./Fabric.Backend.Guild
dockerfile: Dockerfile
container_name: fabric-backend-guild
restart: unless-stopped
depends_on:
mysql-guild:
condition: service_healthy
environment:
FABRIC_BACKEND_GUILD_PORT: 7002
FABRIC_BACKEND_GUILD_DB_HOST: mysql-guild
FABRIC_BACKEND_GUILD_DB_PORT: 3306
FABRIC_BACKEND_GUILD_DB_USER: ${MYSQL_GUILD_USER:-fabric}
FABRIC_BACKEND_GUILD_DB_PASSWORD: ${MYSQL_GUILD_PASSWORD}
FABRIC_BACKEND_GUILD_DB_NAME: ${MYSQL_GUILD_DATABASE:-fabric_guild}
FABRIC_BACKEND_GUILD_DB_SYNC: "false"
FABRIC_BACKEND_GUILD_DB_LOGGING: "false"
FABRIC_BACKEND_GUILD_CENTER_BASE_URL: ${FABRIC_BACKEND_GUILD_CENTER_BASE_URL:-http://backend-center:7001}
FABRIC_BACKEND_GUILD_NODE_ID: ${FABRIC_BACKEND_GUILD_NODE_ID:-guild-node-1}
FABRIC_BACKEND_GUILD_CENTER_API_KEY: ${FABRIC_BACKEND_GUILD_CENTER_API_KEY}
FABRIC_WEBHOOK_URL: ${FABRIC_WEBHOOK_URL:-}
FABRIC_WEBHOOK_SECRET: ${FABRIC_WEBHOOK_SECRET:-}
ports:
- "7002:7002"
volumes:
mysql_center_data:
mysql_guild_data:

View File

@@ -8,7 +8,6 @@ services:
MYSQL_DATABASE: fabric_center
MYSQL_USER: fabric
MYSQL_PASSWORD: fabric
command: ["--default-authentication-plugin=mysql_native_password"]
ports:
- "3307:3306"
volumes:
@@ -28,7 +27,6 @@ services:
MYSQL_DATABASE: fabric_guild
MYSQL_USER: fabric
MYSQL_PASSWORD: fabric
command: ["--default-authentication-plugin=mysql_native_password"]
ports:
- "3308:3306"
volumes:
@@ -49,14 +47,16 @@ services:
mysql-center:
condition: service_healthy
environment:
PORT: 7001
DB_HOST: mysql-center
DB_PORT: 3306
DB_USER: fabric
DB_PASSWORD: fabric
DB_NAME: fabric_center
DB_SYNC: "true"
DB_LOGGING: "false"
FABRIC_BACKEND_CENTER_PORT: 7001
FABRIC_BACKEND_CENTER_DB_HOST: mysql-center
FABRIC_BACKEND_CENTER_DB_PORT: 3306
FABRIC_BACKEND_CENTER_DB_USER: fabric
FABRIC_BACKEND_CENTER_DB_PASSWORD: fabric
FABRIC_BACKEND_CENTER_DB_NAME: fabric_center
FABRIC_BACKEND_CENTER_DB_SYNC: "true"
FABRIC_BACKEND_CENTER_DB_LOGGING: "false"
FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET: change-me-access
FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET: change-me-refresh
ports:
- "7001:7001"
@@ -70,14 +70,17 @@ services:
mysql-guild:
condition: service_healthy
environment:
PORT: 7002
DB_HOST: mysql-guild
DB_PORT: 3306
DB_USER: fabric
DB_PASSWORD: fabric
DB_NAME: fabric_guild
DB_SYNC: "true"
DB_LOGGING: "false"
FABRIC_BACKEND_GUILD_PORT: 7002
FABRIC_BACKEND_GUILD_DB_HOST: mysql-guild
FABRIC_BACKEND_GUILD_DB_PORT: 3306
FABRIC_BACKEND_GUILD_DB_USER: fabric
FABRIC_BACKEND_GUILD_DB_PASSWORD: fabric
FABRIC_BACKEND_GUILD_DB_NAME: fabric_guild
FABRIC_BACKEND_GUILD_DB_SYNC: "true"
FABRIC_BACKEND_GUILD_DB_LOGGING: "false"
FABRIC_BACKEND_GUILD_CENTER_BASE_URL: http://backend-center:7001
FABRIC_BACKEND_GUILD_NODE_ID: guild-node-1
FABRIC_BACKEND_GUILD_CENTER_API_KEY: ${FABRIC_BACKEND_GUILD_CENTER_API_KEY:-}
ports:
- "7002:7002"

59
docs/DEPLOY_AUTH_FLOW.md Normal file
View File

@@ -0,0 +1,59 @@
# Fabric 部署鉴权流程Center / Guild / Frontend
## 1) Admin 注册 Guild在 Center 本机执行)
```bash
scripts/register-guild-node.sh <node_id> <name> <endpoint>
```
成功后会输出:
```bash
FABRIC_BACKEND_GUILD_CENTER_API_KEY=...
```
> `nodes/register` 仅允许 localhost 调用。
---
## 2) 部署 Guild
在 Guild 的部署配置(`.env` / compose填写
- `FABRIC_BACKEND_GUILD_CENTER_BASE_URL`
- `FABRIC_BACKEND_GUILD_CENTER_API_KEY`(上一步拿到)
- `FABRIC_BACKEND_GUILD_NODE_ID`
Guild 启动前会强校验这三项,缺失即启动失败。
---
## 3) Frontend 登录
登录页填写:
- `Center API Base URL`
- `Center API Key`
- 用户名/密码
登录成功后Center 返回:
- 用户可访问 guild 列表
- 每个 guild 对应的访问 token
Frontend 使用这些 token 直连各 Guild 拉 channels/messages。
---
## 4) Center API 访问规则
- `POST /api/nodes/register`:无需 API Key但仅 localhost
- 其他 Center API全部需要 `x-api-key`
---
## 5) 常见错误
- `401 missing/invalid api key`Center API Key 未传或错误
- `403 register endpoint only allows localhost caller`:注册接口不是本机调用
- Guild 启动失败 `Missing required env`:缺 `FABRIC_BACKEND_GUILD_CENTER_BASE_URL` / `FABRIC_BACKEND_GUILD_CENTER_API_KEY` / `FABRIC_BACKEND_GUILD_NODE_ID`

43
docs/MVP-DoD.md Normal file
View File

@@ -0,0 +1,43 @@
# Fabric MVP DoDDefinition of Done
## 1. 范围
本 DoD 面向当前 Fabric Web + Desktop MVP
- Frontend登录、工作台、聊天主链路、实时
- DesktopElectron 壳、安全基线、托盘、打包)
- API 接入Guild/CenterAPI Key 模型)
## 2. 完成标准
### 2.1 Frontend
- [x] 登录流程可用Center
- [x] Guild/Channel 浏览可用
- [x] 消息收发改删可用
- [x] 实时事件可见created/updated/deleted
- [x] typing/在线状态可见
- [x] 异常态loading/empty/error可用
### 2.2 Desktop
- [x] BrowserWindow 与菜单基础可用
- [x] preload/IPC 白名单可用
- [x] 导航/新窗口限制生效
- [x] 本地配置存储可用
- [x] 系统通知可用
- [x] 托盘与最小化到托盘可用
- [x] Linux 构建产物可生成AppImage/deb/tar.gz
### 2.3 接口与配置
- [x] Guild API 使用 API Key
- [x] Center API 使用 API Key
- [x] Socket 鉴权携带 API Key
- [x] 可通过 runtime config 统一配置 baseURL 与 API Key
## 3. 待验收项(测试相关)
以下保留给联调/验收阶段:
- [ ] 与 Center/Guild 联调通过(登录、发消息、实时)
- [ ] 关键链路冒烟Web + Desktop
## 4. 发布前阻断项
- [ ] 将 Desktop `package.json` 中占位 maintainer 邮箱替换为正式邮箱
- [ ] 补充应用 icon避免使用默认 Electron icon

227
docs/TEST_POINTS.md Normal file
View File

@@ -0,0 +1,227 @@
# Fabric — Test Points
Reference checklist of everything that should be verified across the stack.
Organized by component. Re-run the **whole list** after any infra-level change
(ESM migration, dependency bump, Docker/compose change) since those can
regress any path.
Legend: **Verified** = exercised on the live local stack at least once.
Local stack = `docker compose -f docker-compose.local.yml` (Center :7001,
Guild1 :7002 = `test-guild1`, Guild2 :7003 = `test-guild2`, Frontend :8088).
---
## 1. Fabric.Backend.Center (auth / identity)
| # | Test point | How to verify | Expected |
|---|---|---|---|
| C1 | `user create` CLI | `node dist/cli.js user create --email <e> --password <p>` | `{ok:true,user}` |
| C2 | `user apikey` CLI | `node dist/cli.js user apikey --email <e> [--label l]` | `{ok:true,apiKey:"fak_…"}` once |
| C3 | `POST /auth/login` | valid creds | session: accessToken, refreshToken, user{id,email,name}, guilds, guildAccessTokens |
| C4 | login email validation | email with 1-char TLD (`a@b.c`) | 400 (`@IsEmail`) — login & register both reject |
| C5 | `POST /auth/refresh` / `logout` | with refreshToken | new tokens / `{status:ok}` |
| C6 | `GET /auth/me` | bearer token | `{id,email,name}` |
| C7 | `PATCH /auth/me {name}` | bearer; then re-login | name updated & persists; empty → 4xx; default name = email |
| C8 | `POST /auth/agent/login {apiKey}` | valid key | same shape as login (guilds+tokens) |
| C9 | agent/login bad key | `{apiKey:"nope"}` | 401 |
| C10 | api-key-guard exemptions | login, agent/login, refresh, logout, GET/PATCH me, me/guilds, join, members | reachable without `x-api-key` |
| C11 | `POST /auth/resolve-names {guildNodeId,names}` | api-key auth (guild node key) | name/email → userId, scoped to guild members; unknown omitted |
| C12 | `POST /auth/me/guilds/join` | bearer | membership added |
| C13 | `GET /auth/me/guilds` | bearer | guilds + fresh guildAccessTokens |
| C14 | `GET /auth/guilds/:nodeId/members` | member bearer | members incl. `name` |
| C15 | guild node register | CLI / `POST /api/nodes/register` (localhost) | node + apiKey |
| C16 | `user.name` defaults to email | new user | login/members responses carry name=email until changed |
## 2. Fabric.Backend.Guild — channels
| # | Test point | Expected |
|---|---|---|
| G1 | create channel: `xType` required | missing → 400 |
| G2 | `xType` enum | not in {general,work,report,discuss,triage,custom} → 400 |
| G3 | creator auto-added as channel member | always in `channel_members` |
| G4 | `memberUserIds` added | listed users become members |
| G5 | triage `onDuty` required | missing → 400 |
| G6 | triage onDuty | auto-added member + 1 `wake_mapping` row |
| G7 | custom `listeners` | one `wake_mapping` row per listener (optional/empty ok) |
| G8 | `POST /channels/:id/close` | member (or public) → ok; non-member non-public → 403 |
| G9 | post to closed channel | 409 `{error:"channel_closed"}` (normal + command) |
| G10 | closed channel history | `GET …/messages` still 200 |
| G11 | `listForUser` carries `closed`/`isPublic`/`isMember` | present & correct |
| G12 | `POST /channels/:id/join` | public → member; non-public non-member → 403; idempotent |
| G13 | `POST /channels/:id/leave` | removes `channel_members` + `wake_mapping` rows + turn order entry |
| G14 | `GET /channels/:id/members` | explicit member userIds |
| G15 | `listForUser` visibility | public OR explicit member only |
| G16 | create discuss/work with `bypassUserIds` | order = members bypass (∩ members), sorted; bypass stored; disjoint partition |
| G17 | `POST /channels/:id/bypass {userId}` | any channel member actor; target must be a member; discuss/work only (else 400); moves target order→bypass |
| G18 | `GET /channels/:id/members` carries `bypass` | each row `{userId, bypass:boolean}` from turn state |
## 3. Fabric.Backend.Guild — messaging / wakeup
| # | Test point | Expected |
|---|---|---|
| W1 | one message-id; metadata at push only | `message.created` socket payload = view + `channelId` + per-recipient `wakeup` |
| W2 | author rule (precedence) | author's own message → `wakeup=false` (overrides all) |
| W3 | general (no mention) | all recipients true except author |
| W4 | general + `<@id>` | only at'd (mentioned author) true; others false |
| W5 | report | all false |
| W6 | triage / custom | only `wake_mapping` users true |
| W7 | mention parse | `<@id>` counts only outside backtick spans |
| W8 | name-mention translation | `<@user.name:NAME>``<@userId>` (Center resolve), outside backticks |
| W9 | name-mention unresolved | left literal |
| W10 | name-mention in backticks | left literal (not translated) |
## 4. Fabric.Backend.Guild — slash commands
| # | Test point | Expected |
|---|---|---|
| S1 | command registry | only registered (`/no-reply`,`/force-proceed`) intercepted |
| S2 | unknown `/x` (e.g. `/etc/passwd`) | delivered as a normal message |
| S3 | command never delivered | no message row; response `{status:command}` |
| S4 | `/no-reply` outside discuss/work | swallowed, no effect |
## 5. Fabric.Backend.Guild — discuss/work turn engine
| # | Test point | Expected |
|---|---|---|
| T1 | init state | `currentSpeaker = NULL`, order = members sorted by id, frames `[]` |
| T2 | single-member channel | `currentSpeaker` stays `NULL` forever |
| T3 | activation (from NULL) | speaker X → moved to order[0], `currentSpeaker = order[1]` |
| T4 | advance (current speaker normal) | `currentSpeaker → successor`; wakeup only successor |
| T5 | queue-jump (non-current normal) | no advance; all wakeup false; resets cross-round no-reply streak |
| T6 | queue-jump `/no-reply` | swallowed; does NOT reset streak |
| T7 | current `/no-reply` | guild `/ack` emitted; advance; streak += sender |
| T8 | all-members consecutive `/no-reply` (cross-round) | pause → `currentSpeaker=NULL`; resumes on next proactive normal msg |
| T9 | end-of-round shuffle | trailing `/no-reply` run → tail; anchor = last "prev not /nr & self /nr"; head shuffled; head[0] ≠ last normal speaker (D) |
| T10 | shuffle infeasible (head empty / only D) | pause instead of shuffle |
| T11 | `/force-proceed` | skip current (not recorded, streak untouched), advance; at order[-1] triggers shuffle |
| T12 | member join mid-rotation | appended to order tail |
| T13 | member leave | removed from order/streak/frames; if current → successor takes over |
| T14 | mention sub-frame push | current-speaker mention → push `{order:atList, idx:0}`; atList = mentionssender ∩ members; effective current = atList[0] |
| T15 | sub-frame single pass | each member acts once (real / `/no-reply` / `/force-proceed`) → pop |
| T16 | sub-frame pop | parent restored at saved pointer (the pusher); root `current_speaker` column preserved during sub-frame |
| T17 | nested sub-frames | mention inside a sub-frame pushes deeper; pops in order |
| T18 | backtick mention | `` `<@id>` `` does NOT push a sub-frame |
| T19 | `/ack` message | author=`guild`, content `/ack`, persisted (own message-id+seq), wakeup=true only for new currentSpeaker (else all false) |
| T20 | bypass excluded from rotation | bypass member never becomes currentSpeaker / never woken by normal rotation |
| T21 | mentioned bypass member | current-speaker mention of a bypass user → pushed into a sub-frame (transient), woken; on pop returns to bypass (not added to root order) |
| T22 | `moveToBypass` mid-rotation | target removed from order/streak/frames, added to bypass; if target was currentSpeaker → successor takes over (null if order empties) |
| T23 | mention nesting cap | max 4 sub-frames (5 levels incl. root); 5th push evicts bottom-most: root→A→B→C→D + E ⇒ root→B→C→D→E |
| T24 | member-leave strips bypass | leaver removed from `bypassUserIds` too (no orphan) |
## 6. Fabric.Frontend
| # | Test point | Expected |
|---|---|---|
| F1 | login screen | dark card; default Center base; no API key field |
| F2 | channels list | only member/public; per-x_type color; xType badge |
| F3 | create-channel modal | name; required Type select; Public checkbox default off; member list **excludes self**; creator auto-added note |
| F4 | triage in modal | required On-duty single-select, default = current user |
| F5 | custom in modal | optional Listeners multi-select |
| F6 | members sidebar | non-public channel selected → split "In channel" / "Guild"; public/no-channel → single list; shows name + (you) |
| F7 | join/leave buttons | topbar shows Join (non-member, public) / Leave (member) |
| F8 | closed channel | composer replaced by read-only banner; history still rendered |
| F9 | markdown render | per-message; HTML-escaped (XSS-safe); unclosed code fence contained to that message; no cross-message bleed |
| F10 | `<@id>` mention chip | `@DisplayName` pill (resolved via members; short-id fallback); backtick-wrapped literal; untranslated `<@user.name:>` literal |
| F11 | name edit (Settings) | PATCH /auth/me; reflected after save |
| F12 | dev mode toggle | shows guild `/ack` + per-message `wakeup` metadata; hidden when off; persists in localStorage |
| F13 | guild-token refresh on mount | no stale-token "Failed to load channels" after >15 min / reload |
| F14 | message not split | long agent reply renders/stored as one message (no Discord-style chunking) |
| F15 | Discord-style dark theme | server rail / channel sidebar / messages / members layout |
| F16 | create-modal bypass select | discuss/work only: optional multi-select of guild members; sent as `bypassUserIds`; reset on open/close |
| F17 | members panel bypass UI | discuss/work + in-channel list only: bypass members tagged `bypass`; others show "→ bypass" action calling `POST :id/bypass` |
| F18 | composer file attach | 📎 button + multi file input; selected files shown as removable chips; sent after upload; image preview / download chip rendered (via `?access_token`) |
| F19 | pinned canvas panel | fixed below topbar, independent of message scroll; md→renderMarkdown, text→`<pre>`, html→sandboxed `<iframe sandbox srcdoc>`; collapse/expand |
| F20 | canvas share/edit | Share (no canvas) / Edit (sharer) modal: title, format, source; sharer-only Edit/Remove; live update via `canvas.updated`/`canvas.removed` sockets; channel & messages context menus |
## 7. Fabric.OpenclawPlugin (channel plugin)
| # | Test point | Expected |
|---|---|---|
| P1 | build vs real openclaw SDK | `node install.mjs --build-only` clean |
| P2 | install script | `--install` / `--uninstall` / `--openclaw-profile-path`; copies to `~/.openclaw/plugins/fabric` + `openclaw config` |
| P3 | plugin loads & registers | `openclaw channels list` → `Fabric … installed, configured, enabled` |
| P4 | independence | no openclaw source modified (plugin dir + config only) |
| P5 | agent auth | `channels.fabric.accounts.<agentId>.fabricApiKey` → `agent/login` session |
| P6 | inbound transport | one socket per agent; joins channel rooms; logs connect/join |
| P7 | wakeup → admission | `wakeup:true` → dispatch (model runs, reply delivered). `wakeup:false` → `recordInboundSession` only: message enters the agent's OpenClaw session as history/context, **model NOT run, nothing sent back** (no `/no-reply` — turn engine expects silence from non-woken agents). Verified: log `recorded (no wakeup, history only)`, 0 dispatch/deliver/posted |
| P8 | account → agent routing | requires `cfg.bindings` `{agentId,match:{channel:"fabric",accountId}}`; else falls back to default agent |
| P9 | dispatch | `runtime.channel.turn` path: `resolveAgentRoute` + `finalizeInboundContext` + `dispatchInboundReplyWithBase` |
| P10 | outbound | agent reply posted back to Fabric **as the agent**, exactly **one** message (no chunking; `disableBlockStreaming`) |
| P11 | tools | `fabric-register`; `create-{chat,work,report,discussion}-channel` (→ x_type); `discussion-complete` (summary + close) |
| P12 | gateway lifecycle | starts inbound on `gateway_start`, stops on `gateway_stop`; no separate sidecar |
| P13 | full round-trip | human posts in Fabric → wakeup → agent runs → reply lands in channel as agent |
| P14 | file delivery to agent | message attachments downloaded with the agent's guild token to a temp dir; **only local** `MediaPaths`/`MediaTypes` (+ singular) set on the finalized inbound context. No `MediaUrls` — the guild URL is a private host and openclaw's SSRF guard blocks re-fetching it (verified live: `fabric: fetched N attachment(s)`, SSRF WARN gone after the fix) |
## 8. Fabric.Backend.Guild — files & canvas
| # | Test point | Expected |
|---|---|---|
| FC1 | `POST /files` (multipart) | returns `{fileId,url:/api/files/:id,name,mimeType,size,expiresAt}`; `expiresAt ≈ now + TTL` (default 7d) |
| FC2 | size limit | `> FABRIC_BACKEND_GUILD_FILE_MAX_BYTES` (default 100MB, operator-configurable) → 400 |
| FC3 | `GET /files/:id` auth | reachable with Bearer **or** `?access_token=` (browser `<img>/<a>`); no token → 401; image/pdf/av inline, else attachment |
| FC4 | bytes round-trip | downloaded content byte-identical to upload |
| FC5 | retention sweep | rows past `expiresAt` purged with their blob on boot + hourly (`FilesService.cleanup`) |
| FC6 | message attachments | `attachments[]` persisted on the message and returned by `GET …/messages` |
| FC7 | canvas single-active | one row per channel (unique `channel_id`); `GET` null when none |
| FC8 | canvas share (PUT/POST) | caller becomes `sharerUserId`, `version=1`; re-share replaces (resets version, new sharer); emits `canvas.updated` |
| FC9 | canvas update (PATCH) | original sharer only (else 403); `version` increments; emits `canvas.updated` |
| FC10 | canvas delete | sharer only (else 403); emits `canvas.removed` |
| FC11 | canvas access scope | non-member of a non-public channel → 403 on get/share |
## 9. Cross-cutting / infra
| # | Test point | Expected |
|---|---|---|
| X1 | ESM everywhere | all subprojects build & run as ES modules (NodeNext, explicit `.js` imports, CJS deps default-imported) |
| X2 | backends boot under ESM | no `ERR_MODULE` / `jwt.sign is not a function` / interop 500s |
| X3 | local stack bring-up | 2 mysql + Center + 2 Guilds + Frontend healthy; guild nodes registered; users creatable |
| X4 | DB_SYNC schema add | new entities/columns auto-create without data loss (additive) |
| X5 | metadata/message separation | one message-id; metadata only at push; frontend/desktop metadata-agnostic; `author=guild` hidden unless debug |
| X6 | submodule pointers | parent `Fabric` repo bumped after each submodule change; pushed to `origin/main` |
## 10. Slash commands (registry / sync / autocomplete)
| # | Test point | Expected |
|---|---|---|
| SC1 | Guild registry API | `PUT /api/commands {commands}` idempotent full replace; `GET /api/commands` → `{commands,updatedAt}`; both authed (no token → 401). Verified |
| SC2 | plugin builds catalog | `buildFabricCommandSpecs(cfg)` via `openclaw/plugin-sdk/native-command-registry` (`listNativeCommandSpecsForConfig`+`findCommandByNativeName`); dynamic arg `choices` resolved to a static snapshot (`resolveCommandArgChoices`). Verified (41 specs, choices incl. dynamic) |
| SC3 | plugin syncs on start | `syncFabricCommands` runs after inbound on `gateway_start`; PUTs catalog to each connected guild (one per guild, idempotent). Verified via direct call (`synced 41 -> test-guild1`); in-gateway run needs plugin reinstall + gateway restart |
| SC4 | no `nativeCommands` capability | Fabric stays a TEXT-command surface; a `/<cmd>` message is delivered normally → plugin → OpenClaw command system. Only `/no-reply`,`/force-proceed` stay server-intercepted |
| SC5 | frontend autocomplete | `/` opens a command panel (filter, ↑↓/Enter/Esc); pick inserts `/<nativeName> `; arg stage shows args + clickable `choices`. Built & deployed; **browser interaction not automated** — catalog fetch + bundle wiring verified |
| SC6 | command execution | a registered `/<cmd>` reuses the existing message→plugin→OpenClaw path (text-command + command session + auth); not re-verified end-to-end here (same path as P13/P14) |
---
## Known coverage gaps / notes
- **Frontend (§6)** is build-verified (tsc) and was manually checked when
built; not browser-exercised in automated runs. ESM migration did not touch
the frontend.
- **Plugin polish (not yet hardened):** per-agent 15-min token refresh for
long-lived sockets (currently re-auth only on gateway restart); messages
posted before an agent socket finishes joining are missed (no backfill);
guild-token reconnect handling is minimal. Phase 2 (B2 firehose) not built.
- **Files & canvas (§8)** backend is automated-e2e verified (upload,
`?access_token` download, 401, attachment persistence, canvas
share/update/replace/delete + sharer-only/access enforcement; retention
deadline asserted, sweep logic unit-level only — not waited out).
- **Plugin file delivery (P14) — agent file receipt PROVEN live.**
After repointing the fabric binding to a real agent
(`bindings[*].agentId` `echo`→`home-developer`, per `~/.openclaw/
openclaw.json` — `echo` was never a defined agent), a human posted a
file in Fabric → `wakeup` → plugin admitted → **downloaded the
attachment with the agent's guild token** to a temp dir → set local
`MediaPaths` → the openclaw agent then invoked its `read` tool on
exactly that file (`/tmp/fabric-media-echo-<msgId>/0-<name>` in the
log). i.e. the uploaded file demonstrably reaches and is opened by the
agent. Bug found & fixed during this test: `MediaUrls` (a `localhost`
URL) tripped openclaw's SSRF guard — now only local `MediaPaths`/
`MediaTypes` are passed.
- The agent→Fabric **final-reply leg (P13) still not observed**: the
local kimi-backed agent ends its turn after the tool call without
emitting final text (no agent reply on any channel all day), so the
plugin's `deliver` is never called. Pre-existing local model/agent
behavior, independent of the Fabric files/canvas/plugin code.
- `discuss`/`work` differ only in x_type label; turn semantics identical —
test one, both covered.
- Desktop / Android submodules are out of scope (untouched).

View File

@@ -27,11 +27,11 @@
- [x] node 唯一性校验nodeId/endpoint
- [x] node 状态模型active/offline/revoked
- [x] `GET /nodes` 列表 + 分页
- [ ] node 心跳接口(可选)
- [x] node 心跳接口(可选)
### 1.3 Center 运维能力
- [ ] 审计日志auth/node 关键操作)
- [ ] 健康检查深化DB ready
- [x] 审计日志auth/node 关键操作)
- [x] 健康检查深化DB ready
- [x] 配置校验(启动时必填项检查)
---
@@ -39,65 +39,65 @@
## 2. Fabric.Backend.GuildGuild Node
### 2.1 领域模型
- [ ] Guild/Channel/DM 实体补全
- [ ] Member/Role 基础模型(即使 MVP 权限全开,也先留结构)
- [ ] 索引设计channel_id + seq, created_at 等)
- [x] Guild/Channel/DM 实体补全
- [x] Member/Role 基础模型(即使 MVP 权限全开,也先留结构)
- [x] 索引设计channel_id + seq, created_at 等)
### 2.2 消息主链路
- [ ] 发送消息content/reply/mentions/attachments 元数据)
- [ ] 编辑消息(可编辑窗口策略先简化)
- [ ] 删除消息(软删 vs 硬删,先定策略)
- [ ] `GET messages` 分页seq 区间 + limit
- [ ] seq 分配改为 DB 原子方案(避免并发冲突)
- [x] 发送消息content/reply/mentions/attachments 元数据)
- [x] 编辑消息(可编辑窗口策略先简化)
- [x] 删除消息(软删 vs 硬删,先定策略)
- [x] `GET messages` 分页seq 区间 + limit
- [x] seq 分配改为 DB 原子方案(避免并发冲突)
### 2.3 一致性与回补
- [ ] 回补接口:`seq_from/seq_to`
- [ ] 断片检测辅助响应字段next_expected_seq 等)
- [ ] 幂等键支持(写接口)
- [x] 回补接口:`seq_from/seq_to`
- [x] 断片检测辅助响应字段next_expected_seq 等)
- [x] 幂等键支持(写接口)
### 2.4 实时通信MVP 后半)
- [ ] WebSocket 网关接入
- [ ] message.created/updated/deleted 事件广播
- [ ] 在线状态 + typing 事件
- [x] WebSocket 网关接入
- [x] message.created/updated/deleted 事件广播
- [x] 在线状态 + typing 事件
---
## 3. Center ↔ Guild 协议层
- [ ] 鉴权方案定稿node token / HMAC
- [ ] 注册握手协议文档化
- [ ] 错误码与重试策略统一
- [ ] 版本协商(`X-Fabric-Version`
- [x] 鉴权方案定稿node token / HMAC
- [x] 注册握手协议文档化
- [x] 错误码与重试策略统一
- [x] 版本协商(`X-Fabric-Version`
---
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
- [ ] Webhook 事件信封落地event_id/event_type/occurred_at/data
- [ ] HMAC 签名与重放防护
- [ ] 出站重试队列(指数退避)
- [ ] Bot Token 入站调用鉴权
- [x] Webhook 事件信封落地event_id/event_type/occurred_at/data
- [x] HMAC 签名与重放防护
- [x] 出站重试队列(指数退避)
- [x] API Key 入站调用鉴权(不区分 Bot/人类)
---
## 5. 测试与质量门禁
### 5.1 自动化测试
- [ ] 单元测试auth/service/message/seq
- [ ] 集成测试MySQL + API
- [ ] 合约测试Center-Guild 协议)
- [x] 单元测试auth/service/message/seq
- [x] 集成测试MySQL + API
- [x] 合约测试Center-Guild 协议)
### 5.2 质量门禁
- [ ] lint/typecheck/build 全绿
- [ ] API 文档OpenAPI/Swagger
- [x] lint/typecheck/build 全绿
- [x] API 文档OpenAPI/Swagger
- [ ] 关键链路压测(发送/拉取/回补)
---
## 6. 部署与运维
- [ ] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`
- [ ] DB migration 机制TypeORM migration
- [ ] 结构化日志 + request id
- [ ] 基础监控指标QPS、延迟、错误率
- [ ] 备份与恢复流程文档
- [x] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`
- [x] DB migration 机制TypeORM migration
- [x] 结构化日志 + request id
- [x] 基础监控指标QPS、延迟、错误率
- [x] 备份与恢复流程文档
---
@@ -112,9 +112,9 @@
---
## 8. Definition of DoneMVP
- [ ] 用户可注册登录
- [x] 用户可注册登录
- [ ] Guild/Channel/DM 可创建并发消息
- [ ] 消息 seq 连续可回补
- [ ] WebSocket 可实时收发
- [ ] 插件可通过 Bot Token 写入消息并接收 webhook
- [ ] docker-compose 一键部署可用
- [x] 消息 seq 连续可回补
- [x] WebSocket 可实时收发
- [x] 插件可通过统一 API Key 写入消息并接收 webhook
- [x] docker-compose 一键部署可用

View File

@@ -0,0 +1,87 @@
# TODO - Frontend / Desktop 开发计划
## 0. 基础约束
- [x] 技术栈Frontend = React + Vite + TSDesktop = Electron
- [x] Frontend/Desktop 子模块初始化
- [x] 所有前端接口统一走 Guild/Center APIAPI Key 模型)
---
## 1. FrontendWeb
### 1.1 应用骨架
- [x] 路由骨架(登录页 / 工作台 / 聊天页)
- [x] 全局布局(侧栏 + 主区 + 状态栏)
- [x] API Client 封装baseURL、API Key、错误处理
- [x] Socket 客户端封装(连接、重连、订阅/退订)
### 1.2 认证与会话
- [x] 登录页Center 登录)
- [x] Access/Refresh token 管理
- [x] 过期自动刷新与登出
- [x] 基础路由守卫(未登录跳转)
### 1.3 Guild/Channel 浏览
- [x] Guild 列表加载
- [x] Channel 列表加载
- [x] 频道切换与 URL 状态同步
### 1.4 消息主链路
- [x] 消息拉取(分页/区间)
- [x] 发送消息
- [x] 编辑消息
- [x] 删除消息
- [x] 回补提示next_expected_seq
### 1.5 实时能力
- [x] 实时消息created/updated/deleted
- [x] typing/在线状态显示
- [x] 断线重连与重拉策略
### 1.6 体验与稳定性
- [x] 基础 loading/empty/error 态
- [x] 关键页面可观测日志requestId
- [x] 前端构建与 lint 门禁
---
## 2. DesktopElectron
### 2.1 桌面壳
- [x] BrowserWindow 配置(尺寸、最小尺寸、标题)
- [x] Dev/Prod 加载策略devServer / 本地包)
- [x] 应用菜单与快捷键基础
### 2.2 安全基线
- [x] contextIsolation 保持开启
- [x] preload + IPC 白名单(最小暴露)
- [x] 禁止任意导航/新窗口策略
### 2.3 桌面能力
- [x] 本地配置存储API Base/API Key
- [x] 系统通知(新消息)
- [x] 托盘与最小化到托盘(可选)
### 2.4 打包发布
- [x] 打包配置Linux/macOS/Windows
- [x] 版本号与产物命名规范
- [x] 一键构建命令与发布说明
---
## 3. 联调与验收
- [ ] 与 Center/Guild 联调通过(登录、发消息、实时)
- [ ] 关键链路冒烟Web + Desktop
- [x] MVP DoD 文档更新
---
## 4. 推荐执行顺序
1. Frontend 1.1 应用骨架
2. Frontend 1.2 认证与会话
3. Frontend 1.3/1.4 核心聊天
4. Frontend 1.5 实时完善
5. Desktop 2.1/2.2 安全壳
6. Desktop 2.3 桌面增强
7. Desktop 2.4 打包发布
8. 联调验收

View File

@@ -0,0 +1,83 @@
# Fabric 备份与恢复 Runbookv1
## 1. 范围
- MySQL Center`fabric_center`
- MySQL Guild`fabric_guild`
- 可选:持久卷级别备份(`mysql_center_data` / `mysql_guild_data`
---
## 2. 备份策略(建议)
- 频率:每天 1 次全量(低峰期)
- 保留:最近 7~14 天
- 方式:`mysqldump`(逻辑备份)+ 异地对象存储
- 校验:每周至少一次恢复演练
---
## 3. 手动备份命令
> 在 `Fabric/` 目录执行
### 3.1 备份 Center
```bash
docker exec fabric-mysql-center sh -lc \
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick --routines --events fabric_center' \
> backup-center-$(date +%F-%H%M%S).sql
```
### 3.2 备份 Guild
```bash
docker exec fabric-mysql-guild sh -lc \
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick --routines --events fabric_guild' \
> backup-guild-$(date +%F-%H%M%S).sql
```
### 3.3 压缩
```bash
gzip backup-center-*.sql
gzip backup-guild-*.sql
```
---
## 4. 恢复流程
### 4.1 停写(建议)
- 先停 `backend-center` / `backend-guild`,避免恢复时写入冲突。
### 4.2 恢复 Center
```bash
gunzip -c backup-center-YYYY-MM-DD-HHMMSS.sql.gz | \
docker exec -i fabric-mysql-center sh -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" fabric_center'
```
### 4.3 恢复 Guild
```bash
gunzip -c backup-guild-YYYY-MM-DD-HHMMSS.sql.gz | \
docker exec -i fabric-mysql-guild sh -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" fabric_guild'
```
### 4.4 恢复后检查
- 启动后端服务
- 调用:
- `GET /api/healthz`
- `GET /api/metrics`
- 随机抽查:
- 用户登录
- 节点注册列表
- 消息拉取/回补
---
## 5. 演练清单
- [ ] 恢复耗时记录
- [ ] 数据一致性抽样
- [ ] 回滚预案验证
- [ ] 文档更新(命令/版本/风险)
---
## 6. 风险与注意事项
- 恢复前先确认目标库环境(避免误写生产)
- 密钥与密码不要写入仓库,统一走环境变量
- `DB_SYNC` 在生产保持 `false`,结构变更走 migration

View File

@@ -0,0 +1,94 @@
# Center ↔ Guild 注册握手协议 v1HMAC
## 1. 目标
- 让 Guild Node 在注册时证明自己持有共享密钥(`CENTER_SHARED_SECRET`
- 防止中间人篡改与重放攻击
---
## 2. 注册接口
- **Method:** `POST`
- **Path:** `/api/nodes/register`
- **Content-Type:** `application/json`
### Body
```json
{
"nodeId": "guild-node-1",
"name": "Guild Node 1",
"endpoint": "http://guild-node-1:7002"
}
```
### Required Headers
- `X-Fabric-Version`: 协议版本,当前固定为 `1`
- `X-Fabric-Timestamp`: ISO8601 UTC 时间(如 `2026-05-12T11:00:00.000Z`
- `X-Fabric-Nonce`: 随机字符串(建议 UUID
- `X-Fabric-Signature`: HMAC-SHA256 十六进制串
---
## 3. Canonical String
签名输入格式(换行拼接):
```text
{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{BODY_JSON}
```
示例:
```text
POST
/api/nodes/register
2026-05-12T11:00:00.000Z
f8b3a8dc-3aeb-44fc-a4a1-36f8b6c27739
{"nodeId":"guild-node-1","name":"Guild Node 1","endpoint":"http://guild-node-1:7002"}
```
签名算法:
```text
signature = HMAC_SHA256_HEX(CENTER_SHARED_SECRET, canonicalString)
```
---
## 4. Center 侧校验规则
1. 必须包含三项头:`signature/timestamp/nonce`
2. `timestamp` 与服务端时间偏差不超过 5 分钟
3. 使用相同 canonical 规则重新计算签名
4. `timingSafeEqual` 比较签名
5. 签名通过后再做业务校验:
- nodeId 唯一
- endpoint 唯一
---
## 5. 响应语义
### 成功
- `200 OK`
- body 含 `status: accepted` 与 node 信息
### 失败(建议)
- `403`:签名头缺失/签名错误/时间窗非法
- `409`nodeId 或 endpoint 冲突
- `400`:参数非法
---
## 6. 安全建议
- `CENTER_SHARED_SECRET` 长度至少 32 字节
- 定期轮换 secret可采用双 key 过渡)
- 在网关层启用 HTTPS
- 后续可增加 nonce 去重表,防止时间窗内重放
---
## 7. 版本协商(预留)
当前版本:`v1`
当前实现要求请求头:
- `X-Fabric-Version: 1`
若版本不匹配Center 返回:
- `400`
- `error.code = FABRIC_VERSION_NOT_SUPPORTED`
- `supportedVersion = "1"`

View File

@@ -0,0 +1,75 @@
# Fabric 错误码与重试策略 v1
## 1) 统一错误响应结构
所有非 2xx 响应建议返回:
```json
{
"error": {
"code": "FABRIC_XXX",
"message": "human readable",
"retryable": false
}
}
```
可选字段:
- `requestId`: 请求追踪 id
- `details`: 参数错误详情
---
## 2) 业务错误码v1
### 通用
- `FABRIC_BAD_REQUEST` → 400参数不合法
- `FABRIC_UNAUTHORIZED` → 401认证失败
- `FABRIC_FORBIDDEN` → 403鉴权/签名失败)
- `FABRIC_NOT_FOUND` → 404资源不存在
- `FABRIC_CONFLICT` → 409资源冲突/重复)
- `FABRIC_RATE_LIMITED` → 429限流
- `FABRIC_INTERNAL_ERROR` → 500服务内部错误
- `FABRIC_UNAVAILABLE` → 503依赖不可用
### Center↔Guild 协议
- `FABRIC_HMAC_MISSING_HEADERS` → 403
- `FABRIC_HMAC_INVALID_SIGNATURE` → 403
- `FABRIC_HMAC_TIMESTAMP_EXPIRED` → 403
- `FABRIC_NODE_ID_CONFLICT` → 409
- `FABRIC_NODE_ENDPOINT_CONFLICT` → 409
### Messaging
- `FABRIC_EDIT_WINDOW_EXPIRED` → 409
- `FABRIC_IDEMPOTENCY_REPLAY` → 200命中幂等缓存非错误
---
## 3) 重试策略(客户端/插件侧)
### 可重试(指数退避)
- HTTP: `429`, `500`, `502`, `503`, `504`
- 网络异常:超时/连接重置/临时 DNS 故障
### 不可重试
- HTTP: `400`, `401`, `403`, `404`, `409`(除非业务明确允许)
### 退避规则
- 基础间隔1s
- 退避序列1s / 2s / 4s / 8s / 16s
- 最大重试次数5
- 加抖动:`±20%`
- 若有 `Retry-After`:优先按 `Retry-After`
---
## 4) 幂等与重试配合
- 写接口重试必须带 `Idempotency-Key`
- 同 key 重放返回首次响应,避免重复写入
- 幂等记录建议至少保留 24h
---
## 5) 服务端落地建议
- 网关/中间件统一异常映射为标准 `error.code`
- 在日志中记录:`error.code``requestId``status`
- 对 429/503 返回 `Retry-After`

47
scripts/register-guild-node.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage:
# scripts/register-guild-node.sh <node_id> <name> <endpoint>
# Example:
# scripts/register-guild-node.sh guild-node-1 "Guild Node 1" "http://backend-guild:7002"
if [[ $# -ne 3 ]]; then
echo "Usage: $0 <node_id> <name> <endpoint>" >&2
exit 1
fi
NODE_ID="$1"
NODE_NAME="$2"
NODE_ENDPOINT="$3"
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found" >&2
exit 1
fi
if ! docker compose ps backend-center >/dev/null 2>&1; then
echo "backend-center service is not available in current docker compose project" >&2
exit 1
fi
RESULT_JSON=$(docker compose exec -T backend-center \
npm run -s cli -- node register --node-id "$NODE_ID" --name "$NODE_NAME" --endpoint "$NODE_ENDPOINT")
OK=$(node -e "const o=JSON.parse(process.argv[1]);process.stdout.write(String(!!o.ok));" "$RESULT_JSON")
if [[ "$OK" != "true" ]]; then
echo "registration failed: $RESULT_JSON" >&2
exit 1
fi
API_KEY=$(node -e "const o=JSON.parse(process.argv[1]);process.stdout.write(o.apiKey||'');" "$RESULT_JSON")
NODE_ID_OUT=$(node -e "const o=JSON.parse(process.argv[1]);process.stdout.write(o.node?.nodeId||'');" "$RESULT_JSON")
if [[ -z "$API_KEY" ]]; then
echo "registration succeeded but no apiKey returned: $RESULT_JSON" >&2
exit 1
fi
echo "Node registered: $NODE_ID_OUT"
echo "FABRIC_BACKEND_GUILD_CENTER_API_KEY=$API_KEY"