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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 56 additions & 12 deletions forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,15 @@ account that owns the app:
forge webtrigger --environment production
```

Pick the installed tenant when prompted. The CLI prints two URLs:
Pick the installed tenant when prompted. The CLI prints three URLs:
- `drain` → the URL Mattermost will poll
- `register` → a one-shot URL used to set the shared secret
- `reset` → HMAC-authenticated URL used by `/confluence forge reset` to
rotate the shared secret in-band

You only need to paste `drain` and `register` into the wizard; the
bridge announces the `reset` URL back in its `register` response and
the plugin persists it automatically.

### Step 8 — Run the Mattermost setup wizard

Expand All @@ -121,20 +127,58 @@ notification.

### Operational notes

- `register` is one-shot. If you need to rotate the shared secret,
clear `mm.registered` from Forge storage first (use `forge install
--upgrade` after manually wiping the entry), then re-run the
Mattermost wizard.
- Forge storage values are capped at 240 KiB per entry. The bridge
drops the inline page body for events that would exceed this; the
channel notification still fires but @-mention DMs are skipped for
that single oversized event.
#### Rotating the shared secret

The `register` endpoint is single-use per secret. To rotate without
re-running the install wizard, use the in-band command from Mattermost:

```
/confluence forge reset
```

This calls the bridge's HMAC-authenticated `reset` web trigger, which
wipes `mm.registered`, `mm.drainSecret`, and any queued events; the
plugin then generates a new secret and re-registers automatically.
Polling resumes within ~30s. No `install cloud` rerun required.

If the plugin is missing the `reset` URL on file (e.g. it was installed
before this command existed) but still has the `register` URL, the
command self-heals by calling `register` to fetch the current webtrigger
URLs, persisting them, and then performing the rotation. No manual
re-install required in that case either.

If the in-band reset fails because the plugin and bridge have drifted
out of sync (i.e. the bridge no longer accepts the plugin's HMAC),
break-glass via the Forge CLI:

```
forge invoke -f wipeRegistrationFn -e <environment>
```

This requires Forge developer access to the app. After it returns,
run `/confluence install cloud` to re-register.

#### Storage backend

The bridge uses Forge `@forge/kvs` (the legacy `@forge/api` `storage`
module was removed by Atlassian on 2026-06-22). Keys used:

- `mm.registered` — registration flag (boolean)
- `mm.drainSecret` — shared HMAC secret (Forge secret-type entry)
- `evt:*` — buffered Confluence events, drained by the Mattermost plugin

#### Other limits

- KVS values are capped at 240 KiB per entry. The bridge drops the
inline page body for events that would exceed this; the channel
notification still fires but @-mention DMs are skipped for that
single oversized event.
- Forge web trigger throttle is 1000 req/min per app/environment. At
a 30-second poll cadence that is 2 req/min per tenant, so one bridge
accommodates ~500 Confluence Cloud tenants before throttling.
- Forge storage is wiped 28 days after the app is uninstalled. The
bridge is a buffer, not a system of record; the Mattermost plugin
is the durable side.
- Forge KVS is wiped 28 days after the app is uninstalled. The bridge
is a buffer, not a system of record; the Mattermost plugin is the
durable side.

## Shape

Expand Down
9 changes: 9 additions & 0 deletions forge/manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ modules:
urlFormat: v2
response:
type: dynamic
- key: reset
function: resetFn
urlFormat: v2
response:
type: dynamic

function:
- key: enqueueFn
Expand All @@ -56,8 +61,12 @@ modules:
handler: index.drain
- key: registerFn
handler: index.register
- key: resetFn
handler: index.reset
- key: onInstalledFn
handler: index.onInstalled
- key: wipeRegistrationFn
handler: index.wipeRegistration

app:
runtime:
Expand Down
3 changes: 2 additions & 1 deletion forge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"install:dev": "forge install --upgrade"
},
"dependencies": {
"@forge/api": "^4.0.0"
"@forge/api": "^4.0.0",
"@forge/kvs": "^2.0.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
Expand Down
84 changes: 67 additions & 17 deletions forge/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import api, { route, storage, webTrigger } from '@forge/api';
import api, { route, webTrigger } from '@forge/api';
import { kvs, WhereConditions } from '@forge/kvs';
import { createHmac, timingSafeEqual } from 'crypto';

const QUEUE_PREFIX = 'evt:';
Expand Down Expand Up @@ -34,10 +35,10 @@ export const enqueue = async (event: unknown, context: unknown): Promise<void> =
const safe = enforceStorageLimit(enriched, contentID);

try {
await storage.set(key, { event: safe, context, enqueuedAt: Date.now() });
await kvs.set(key, { event: safe, context, enqueuedAt: Date.now() });
console.log(`enqueue: stored key=${key} bodyAttached=${Boolean(safe.content?.body)}`);
} catch (err) {
console.error(`enqueue: storage.set failed key=${key} error=${(err as Error)?.message ?? err}`);
console.error(`enqueue: kvs.set failed key=${key} error=${(err as Error)?.message ?? err}`);
throw err;
}
};
Expand Down Expand Up @@ -97,7 +98,7 @@ const enrichWithBody = async (evt: ForgeEvent): Promise<ForgeEvent> => {
// body with the shared secret set via the `register` trigger.
export const drain = async (req: WebTriggerRequest): Promise<WebTriggerResponse> => {
console.log('drain: invoked');
const secret = (await storage.getSecret(SECRET_KEY)) as string | undefined;
const secret = (await kvs.getSecret(SECRET_KEY)) as string | undefined;
if (!secret) {
console.log('drain: rejected, bridge not registered');
return jsonResponse(503, { error: 'bridge not registered; POST credentials to register web trigger first' });
Expand All @@ -119,14 +120,14 @@ export const drain = async (req: WebTriggerRequest): Promise<WebTriggerResponse>

if (body.ack?.length) {
const ackable = body.ack.filter((k) => typeof k === 'string' && k.startsWith(QUEUE_PREFIX));
await Promise.all(ackable.map((k) => storage.delete(k)));
await Promise.all(ackable.map((k) => kvs.delete(k)));
console.log(`drain: acked ${ackable.length} keys`);
}

const limit = clampLimit(body.limit);
const results = await storage
const results = await kvs
.query()
.where('key', { condition: 'STARTS_WITH', value: QUEUE_PREFIX })
.where('key', WhereConditions.beginsWith(QUEUE_PREFIX))
.limit(limit)
.getMany();

Expand All @@ -135,9 +136,49 @@ export const drain = async (req: WebTriggerRequest): Promise<WebTriggerResponse>
return jsonResponse(200, { events, nextCursor: results.nextCursor ?? null });
};

// register is a one-shot. Once `mm.registered` is set, further calls are
// refused. To re-register, an operator must clear the flag via the Forge CLI:
// forge install --upgrade then re-POST to register
export const reset = async (req: WebTriggerRequest): Promise<WebTriggerResponse> => {
console.log('reset: invoked');
const secret = (await kvs.getSecret(SECRET_KEY)) as string | undefined;
if (!secret) {
console.log('reset: bridge not registered, nothing to do');
return jsonResponse(200, { ok: true, alreadyClear: true });
}

if (!verifySignature(secret, headerValue(req, 'x-mm-signature'), req.body ?? '')) {
console.log('reset: rejected, invalid signature');
return jsonResponse(403, { error: 'invalid signature' });
}

const queuedDeleted = await wipeAllStorage();
console.log(`reset: cleared registration + ${queuedDeleted} queued events`);
return jsonResponse(200, { ok: true, queuedDeleted });
};

export const wipeRegistration = async (): Promise<{ ok: true; queuedDeleted: number }> => {
const queuedDeleted = await wipeAllStorage();
console.log(`wipeRegistration: cleared registration + ${queuedDeleted} queued events`);
return { ok: true, queuedDeleted };
};

const wipeAllStorage = async (): Promise<number> => {
let cursor: string | undefined;
let deleted = 0;
do {
const q = kvs
.query()
.where('key', WhereConditions.beginsWith(QUEUE_PREFIX))
.limit(100);
if (cursor) q.cursor(cursor);
const page = await q.getMany();
await Promise.all(page.results.map((r) => kvs.delete(r.key)));
deleted += page.results.length;
cursor = page.nextCursor ?? undefined;
} while (cursor);
await kvs.delete(REGISTERED_KEY);
await kvs.deleteSecret(SECRET_KEY);
return deleted;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export const register = async (req: WebTriggerRequest): Promise<WebTriggerResponse> => {
let payload: { secret?: string };
try {
Expand All @@ -150,19 +191,28 @@ export const register = async (req: WebTriggerRequest): Promise<WebTriggerRespon
return jsonResponse(400, { error: 'secret must be at least 32 characters' });
}

if (await storage.get(REGISTERED_KEY)) {
const existing = (await storage.getSecret(SECRET_KEY)) as string | undefined;
if (await kvs.get(REGISTERED_KEY)) {
const existing = (await kvs.getSecret(SECRET_KEY)) as string | undefined;
if (existing && secretsMatch(existing, payload.secret)) {
return jsonResponse(200, { ok: true, alreadyRegistered: true });
return jsonResponse(200, { ok: true, alreadyRegistered: true, urls: await allWebtriggerURLs() });
}
return jsonResponse(409, {
error: 'already registered with a different shared secret; clear mm.registered and mm.drainSecret from Forge storage to reset',
error: 'already registered with a different shared secret; run `/confluence forge reset` in Mattermost to rotate, or `forge invoke -f wipeRegistrationFn -e <env>` to break-glass',
});
}

await storage.setSecret(SECRET_KEY, payload.secret);
await storage.set(REGISTERED_KEY, true);
return jsonResponse(200, { ok: true });
await kvs.setSecret(SECRET_KEY, payload.secret);
await kvs.set(REGISTERED_KEY, true);
return jsonResponse(200, { ok: true, urls: await allWebtriggerURLs() });
};

const allWebtriggerURLs = async (): Promise<{ drain: string; register: string; reset: string }> => {
const [drain, register, reset] = await Promise.all([
webTrigger.getUrl('drain'),
webTrigger.getUrl('register'),
webTrigger.getUrl('reset'),
]);
return { drain, register, reset };
};

const secretsMatch = (a: string, b: string): boolean => {
Expand Down
5 changes: 2 additions & 3 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,8 @@
{
"key": "ForgeSharedSecret",
"display_name": "Forge Bridge Shared Secret",
"type": "generated",
"help_text": "Shared secret used to HMAC-sign poll requests to the Forge bridge that delivers Confluence Cloud events (the GA replacement for the legacy Atlassian Connect descriptor). After regenerating, POST the new value to the Forge app's register web trigger.",
"regenerate_help_text": "Regenerates the shared secret. Existing Forge bridge installations must be re-registered with the new value.",
"type": "text",
"help_text": "Shared secret used to HMAC-sign poll requests to the Forge bridge that delivers Confluence Cloud events (the GA replacement for the legacy Atlassian Connect descriptor). Managed by the plugin — do not edit manually. To rotate on an already-registered bridge, run /confluence forge reset; the bridge's register endpoint is one-shot per secret and will reject a manually regenerated value with HTTP 409.",
"secret": true
},
{
Expand Down
11 changes: 10 additions & 1 deletion server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const (
sysAdminHelpText = "\n###### For System Administrators:\n" +
"Setup Instructions:\n" +
"* `/confluence install cloud` - Connect Mattermost to a Confluence Cloud instance.\n" +
"* `/confluence install server` - Connect Mattermost to a Confluence Server or Data Center instance.\n"
"* `/confluence install server` - Connect Mattermost to a Confluence Server or Data Center instance.\n" +
"* `/confluence forge reset` - Rotate the Forge bridge shared secret in-place (Cloud only).\n"

invalidCommand = "Invalid command."
installOnlySystemAdmin = "`/confluence install` can only be run by a system administrator."
Expand Down Expand Up @@ -73,6 +74,7 @@ var ConfluenceCommandHandler = Handler{
"settings/notifications": executeNotificationsStatus,
"settings/notifications/on": executeNotificationsOn,
"settings/notifications/off": executeNotificationsOff,
"forge/reset": executeForgeReset,
},
defaultHandler: executeConfluenceDefault,
}
Expand Down Expand Up @@ -142,6 +144,13 @@ func getAutoCompleteData() *model.AutocompleteData {
settings.AddCommand(notifications)
confluence.AddCommand(settings)

forge := model.NewAutocompleteData("forge", "", "Manage the Confluence Forge bridge (System Admin)")
forge.RoleID = model.SystemAdminRoleId
reset := model.NewAutocompleteData("reset", "", "Rotate the Forge bridge shared secret")
reset.RoleID = model.SystemAdminRoleId
forge.AddCommand(reset)
confluence.AddCommand(forge)

return confluence
}

Expand Down
2 changes: 2 additions & 0 deletions server/config/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ type Configuration struct {
IsCloud bool `json:"iscloud"`
ForgeSharedSecret string `json:"forgesharedsecret"`
ForgeDrainURL string `json:"forgedrainurl"`
ForgeResetURL string `json:"forgereseturl"`
ForgeRegisterURL string `json:"forgeregisterurl"`
ForgeInstallURL string `json:"forgeinstallurl"`
}

Expand Down
43 changes: 35 additions & 8 deletions server/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -847,12 +847,25 @@ func (fm *FlowManager) submitForgeBridgeURLs(_ *flow.Flow, submitted map[string]
return "", nil, nil, errors.New("Forge Bridge Shared Secret is not set on this plugin; reload the plugin to regenerate it")
}

if err := postForgeRegister(registerURL, cfg.ForgeSharedSecret); err != nil {
urls, err := postForgeRegister(registerURL, cfg.ForgeSharedSecret)
if err != nil {
errorList["register_url"] = err.Error()
return "", nil, errorList, nil
}

cfg.ForgeDrainURL = drainURL
cfg.ForgeRegisterURL = registerURL
if urls != nil {
if isForgeWebtriggerURL(urls.Reset) {
cfg.ForgeResetURL = urls.Reset
}
if isForgeWebtriggerURL(urls.Drain) {
cfg.ForgeDrainURL = urls.Drain
}
if isForgeWebtriggerURL(urls.Register) {
cfg.ForgeRegisterURL = urls.Register
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cfg.Sanitize()
configMap, err := cfg.ToMap()
if err != nil {
Expand Down Expand Up @@ -885,18 +898,29 @@ func isForgeWebtriggerURL(raw string) bool {
return strings.HasPrefix(u.Path, "/public/")
}

func postForgeRegister(registerURL, secret string) error {
type ForgeWebtriggerURLs struct {
Drain string `json:"drain"`
Register string `json:"register"`
Reset string `json:"reset"`
}

type forgeRegisterResponse struct {
OK bool `json:"ok"`
URLs ForgeWebtriggerURLs `json:"urls"`
}

func postForgeRegister(registerURL, secret string) (*ForgeWebtriggerURLs, error) {
body, err := json.Marshal(map[string]string{"secret": secret})
if err != nil {
return errors.Wrap(err, "failed to encode register payload")
return nil, errors.Wrap(err, "failed to encode register payload")
}

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body))
if err != nil {
return errors.Wrap(err, "failed to build register request")
return nil, errors.Wrap(err, "failed to build register request")
}
req.Header.Set("Content-Type", "application/json")

Expand All @@ -909,21 +933,24 @@ func postForgeRegister(registerURL, secret string) error {
}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "failed to reach register URL")
return nil, errors.Wrap(err, "failed to reach register URL")
}
defer resp.Body.Close()

switch resp.StatusCode {
case http.StatusOK, http.StatusNoContent:
return nil
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var parsed forgeRegisterResponse

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Might be nice to also do the same check for parsed.OK that is happening in postForgeReset

_ = json.Unmarshal(respBody, &parsed) // tolerate older bridges with no urls field
return &parsed.URLs, nil
case http.StatusConflict:
return errors.New("Forge bridge is already registered with a different shared secret. Have the Forge admin delete the `mm.registered` and `mm.drainSecret` storage keys (`forge storage delete mm.registered && forge storage delete mm.drainSecret`), then re-run this wizard.")
return nil, errors.New("Forge bridge is already registered with a different shared secret. Run `/confluence forge reset` to rotate, or if that fails ask your Forge admin to run `forge invoke -f wipeRegistrationFn -e <env>`, then re-run this wizard.")
default:
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
snippet := strings.TrimSpace(string(respBody))
if snippet == "" {
snippet = resp.Status
}
return errors.Errorf("bridge rejected registration (%d): %s", resp.StatusCode, snippet)
return nil, errors.Errorf("bridge rejected registration (%d): %s", resp.StatusCode, snippet)
}
}
Loading
Loading