mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: Client-published events + realtime presence
- POST /projects/:id/events accepts client-authored events restricted to
the custom.* namespace — system types can't be spoofed. They flow
through the same publishEvent fan-out: realtime, webhooks, and
event_pattern functions all fire.
- Presence: subscribe/unsubscribe bumps a per-project count and
broadcasts presence.update {online} to the realtime stream only —
deliberately not through publishEvent so webhooks and "*" functions
don't fire on connect noise. GET …/realtime/presence reads the count.
- @primora/client gains publish() and presence(); CLI gains events:send.
Verified live: presence 0->1 on subscribe, presence.update frame
delivered, custom event delivered to SSE + fired a custom.* function,
spoofed document.created rejected 400.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9ae1c22af0
commit
236357facb
16 files changed
+393
-10
No files matched your search
@@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// publishEvent accepts a client-authored domain event (custom.* namespace)
|
||||
// and fans it out through the standard publishEvent path — realtime
|
||||
// subscribers, matching webhooks, and event_pattern functions all fire.
|
||||
func (h *HTTPHandler) publishEvent(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Type string `json:"type" validate:"required"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
if err := h.Platform.PublishProjectEvent(c.Request.Context(), actor, projectID, body.Type, body.Data); err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"published": body.Type})
|
||||
}
|
||||
|
||||
// realtimePresence returns the count of clients currently subscribed to the
|
||||
// project's realtime stream.
|
||||
func (h *HTTPHandler) realtimePresence(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := h.Platform.Presence(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"online": n})
|
||||
}
|
||||
@@ -92,6 +92,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||
api.GET("/projects/:projectID/events", h.listEvents)
|
||||
api.POST("/projects/:projectID/events", h.publishEvent)
|
||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||
api.GET("/projects/:projectID/components", h.listComponents)
|
||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||
@@ -139,6 +140,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
|
||||
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
|
||||
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
|
||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCustomEventTypeRe(t *testing.T) {
|
||||
good := []string{"custom.ping", "custom.deploy.done", "custom.a", "custom.x-y_z.9"}
|
||||
bad := []string{
|
||||
"", "custom", "custom.", "document.created", "CUSTOM.ping",
|
||||
"custom.Ping", "custom..x", "custom.-x", "custom." + string(rune(0x20)) + "x",
|
||||
"custom." + string(make([]byte, 64)),
|
||||
}
|
||||
for _, s := range good {
|
||||
if !customEventTypeRe.MatchString(s) {
|
||||
t.Errorf("expected %q to match", s)
|
||||
}
|
||||
}
|
||||
for _, s := range bad {
|
||||
if customEventTypeRe.MatchString(s) {
|
||||
t.Errorf("expected %q to be rejected", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -42,6 +44,10 @@ type PlatformService struct {
|
||||
alerts *alertEvaluator
|
||||
functions FunctionRunner
|
||||
logger *slog.Logger
|
||||
|
||||
// presenceMu guards presence: realtime subscriber counts per project.
|
||||
presenceMu sync.Mutex
|
||||
presence map[uuid.UUID]int
|
||||
}
|
||||
|
||||
type BootstrapInput struct {
|
||||
@@ -228,7 +234,7 @@ type InvitationSummary struct {
|
||||
}
|
||||
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger}
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
|
||||
if enc != nil {
|
||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||
s.dispatcher.Start(context.Background())
|
||||
@@ -333,16 +339,70 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
|
||||
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||
}
|
||||
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events.
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events and
|
||||
// bumps the project's presence count.
|
||||
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.realtime.Subscribe(projectID.String()), nil
|
||||
ch := s.realtime.Subscribe(projectID.String())
|
||||
s.bumpPresence(projectID, 1)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
||||
s.realtime.Unsubscribe(projectID.String(), ch)
|
||||
s.bumpPresence(projectID, -1)
|
||||
}
|
||||
|
||||
// bumpPresence adjusts the online count and broadcasts presence.update to
|
||||
// realtime subscribers only — deliberately not through publishEvent, so
|
||||
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
|
||||
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
|
||||
s.presenceMu.Lock()
|
||||
n := s.presence[projectID] + delta
|
||||
if n <= 0 {
|
||||
delete(s.presence, projectID)
|
||||
n = 0
|
||||
} else {
|
||||
s.presence[projectID] = n
|
||||
}
|
||||
s.presenceMu.Unlock()
|
||||
s.realtime.Broadcast(projectID.String(), map[string]any{
|
||||
"type": "presence.update",
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": map[string]any{"online": n},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence returns the current realtime subscriber count for a project.
|
||||
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.presenceMu.Lock()
|
||||
defer s.presenceMu.Unlock()
|
||||
return s.presence[projectID], nil
|
||||
}
|
||||
|
||||
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
|
||||
|
||||
// PublishProjectEvent lets a member emit a custom domain event — realtime
|
||||
// subscribers, matching webhooks, and event_pattern functions all fire via
|
||||
// the standard publishEvent fan-out. The custom.* prefix is enforced so
|
||||
// clients can't spoof system event types (document.created etc.).
|
||||
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !customEventTypeRe.MatchString(eventType) {
|
||||
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
s.publishEvent(ctx, projectID, eventType, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||
|
||||
@@ -1331,6 +1331,37 @@ paths:
|
||||
$ref: "#/components/schemas/TelemetryEventListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
post:
|
||||
tags: [Automation]
|
||||
operationId: publishProjectEvent
|
||||
summary: Publishes a client-authored domain event. Type must be in the custom.* namespace; the event fans out to realtime subscribers, matching webhooks, and event_pattern functions.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishEventRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Event accepted for fan-out
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
published:
|
||||
type: string
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/issues:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
@@ -1887,6 +1918,28 @@ paths:
|
||||
description: text/event-stream of {type, occurred_at, data} messages
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/realtime/presence:
|
||||
get:
|
||||
tags: [Automation]
|
||||
operationId: getRealtimePresence
|
||||
summary: Count of clients currently subscribed to the project's realtime stream. The stream also emits presence.update events on every join/leave.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Online subscriber count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
online:
|
||||
type: integer
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/secrets:
|
||||
get:
|
||||
tags: [Secrets]
|
||||
@@ -3191,6 +3244,18 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [url, method, expires_at]
|
||||
PublishEventRequest:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
pattern: '^custom\.[a-z0-9][a-z0-9_.-]{0,62}$'
|
||||
description: Event type in the custom.* namespace — realtime subscribers, matching webhooks, and event_pattern functions all fire.
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Arbitrary JSON payload carried to subscribers.
|
||||
required: [type]
|
||||
PaginationMetadata:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+8
-1
@@ -21,7 +21,14 @@ project interactively.
|
||||
## Commands
|
||||
|
||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||
`jobs`, `audit list --follow`. Every command accepts `--json`.
|
||||
`jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`.
|
||||
|
||||
```sh
|
||||
primora events:send custom.deploy.done --data '{"sha":"abc123"}'
|
||||
```
|
||||
|
||||
`events:send` publishes a `custom.*` event — realtime subscribers, matching
|
||||
webhooks, and `event_pattern` functions all fire.
|
||||
|
||||
```sh
|
||||
primora documents list --collection users \
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cac } from "cac";
|
||||
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||
import { cmdAuditList } from "./commands/audit.js";
|
||||
import { cmdDocumentsList } from "./commands/documents.js";
|
||||
import { cmdEventsSend } from "./commands/events.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||
import { cmdInject } from "./commands/inject.js";
|
||||
@@ -174,6 +175,12 @@ cli
|
||||
.option("--offset <n>", "Page offset")
|
||||
.action(cmdDocumentsList);
|
||||
|
||||
cli
|
||||
.command("events:send <type>", "Publish a custom.* event to realtime subscribers, webhooks, and functions")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--data <json>", "JSON object payload")
|
||||
.action(cmdEventsSend);
|
||||
|
||||
cli
|
||||
.command("audit:list", "List audit events for the current project")
|
||||
.option("--project <id>", "Project override")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AutomationService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { isJson, printJson } from "../out.js";
|
||||
|
||||
interface EventsSendOptions {
|
||||
project?: string;
|
||||
data?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function cmdEventsSend(type: string, options: EventsSendOptions): Promise<void> {
|
||||
if (!type.startsWith("custom.")) {
|
||||
throw new CliError(
|
||||
`Invalid event type "${type}".`,
|
||||
"Client events must live in the custom.* namespace — e.g. custom.deploy.done",
|
||||
);
|
||||
}
|
||||
let data: Record<string, unknown> = {};
|
||||
if (options.data) {
|
||||
try {
|
||||
const parsed = JSON.parse(options.data);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new CliError("--data must be a JSON object.", `Got: ${options.data.slice(0, 60)}`);
|
||||
}
|
||||
data = parsed;
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
throw new CliError("--data is not valid JSON.", (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
|
||||
const res = await AutomationService.publishProjectEvent({
|
||||
projectId,
|
||||
requestBody: { type, data },
|
||||
});
|
||||
if (isJson(options)) {
|
||||
printJson(res);
|
||||
return;
|
||||
}
|
||||
console.log(`Published ${res.published ?? type} — realtime subscribers, matching webhooks, and event_pattern functions fire.`);
|
||||
}
|
||||
Reference in new issue
Block a user