From 3b0abd9a474a737abc361d27de38b769d1266c0c Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Wed, 12 Aug 2026 18:06:59 +0700 Subject: [PATCH 1/2] fix(catalog): enforce the id rules the standard already states, and correct four contract claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLUGIN-STANDARD gives plugin ids a shape and a reserved list. Nothing checked either, so a plugin could take an id the standard forbids and the catalogue would publish it. Both are now enforced from the standard's own wording rather than a paraphrase. Four claims that did not match the corpus: - `sdkVersion` was sent as the string "1" by four manifests while the standard writes it as a number. - The contract described `net.allow` as "host:port" entries; most of the catalogue uses a bare host, which matches any port. The comment now describes both, because both are in use. - The contract header gave one alignment version while later comments describe newer host behaviour it has not been re-verified against. It now says which is which. - chatwoot-adapter's ingress comment read as though its returned status reaches Chatwoot. The host ignores it and computes the provider's reply from the manifest; what the value really controls is retry. The numbers stay, the comment no longer claims more than they do. The i18n check now records its scope: it compares field keys, which carry `title`. No plugin translates a field `description`, so requiring it would fail every locale at once — the guarantee is titles. --- chatwoot-adapter/manifest.json | 2 +- chatwoot-adapter/outbound.ts | 9 +++++++-- http-action/manifest.json | 2 +- scripts/catalog.mjs | 14 ++++++++++++++ scripts/catalog.test.mjs | 3 +++ supabase-otp-hook/manifest.json | 2 +- typebot-connector/manifest.json | 2 +- types/openwa.d.ts | 9 ++++++--- 8 files changed, 34 insertions(+), 9 deletions(-) diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index 739a477..75312a9 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -13,7 +13,7 @@ "status": "stable", "minOpenWAVersion": "0.8.7", "testedOpenWAVersion": "0.14.0", - "sdkVersion": "1", + "sdkVersion": 1, "permissions": ["net:fetch", "conversation:send", "webhook:ingress", "engine:read", "storage:use"], "net": { "allow": [], "allowConfigHosts": ["baseUrl"] }, diff --git a/chatwoot-adapter/outbound.ts b/chatwoot-adapter/outbound.ts index dcb88fe..bceb332 100644 --- a/chatwoot-adapter/outbound.ts +++ b/chatwoot-adapter/outbound.ts @@ -22,8 +22,13 @@ export interface OutboundDeps { } // Chatwoot → WhatsApp. `conversation_updated` drives handover; a relayable `message_created` is sent to -// WhatsApp under the per-chat lock (deduped on the Chatwoot message id). Throwing surfaces to the ingress -// pipeline for retry/DLReturn; a parse error is a 400 (never retried). +// WhatsApp under the per-chat lock (deduped on the Chatwoot message id). +// +// The returned status is NOT what the provider sees: the host computes that from +// `manifest.ingress[].response.ack` and reads only whether this handler resolved or threw +// (types/openwa.d.ts:296). What the number does control is retry — resolving means "accepted, do not +// retry", throwing means "retry". The values below are kept because they read as intent at each exit, +// not because a 400 reaches Chatwoot. export async function handleOutbound(deps: OutboundDeps, req: WebhookRequest): Promise<{ status: number }> { let evt: ChatwootWebhookMessage; try { diff --git a/http-action/manifest.json b/http-action/manifest.json index a703231..84e6ea9 100644 --- a/http-action/manifest.json +++ b/http-action/manifest.json @@ -20,7 +20,7 @@ "status": "stable", "testedOpenWAVersion": "0.14.0", "minOpenWAVersion": "0.8.0", - "sdkVersion": "1", + "sdkVersion": 1, "provides": [ "api-automation", "rest-connector", diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index b4e3974..d168708 100644 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -35,6 +35,12 @@ const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'it', 'ar', 'he', 'te', 'zh-CN', 'z // author; the ARTIFACT must come from a repo this project publishes. const RELEASE_OWNERS = new Set(['rmyndharis']); +// Both taken verbatim from PLUGIN-STANDARD.md — the shape at its manifest example and the reserved +// list at its "Reserved ids" line. Neither was checked anywhere, so a plugin could take an id the +// standard forbids and the catalogue would publish it. +const ID_SHAPE = /^[a-z0-9][a-z0-9._-]*$/i; +const RESERVED_IDS = new Set(['whatsapp-web.js', 'baileys', 'auto-reply', 'translation']); + const HOST_PERMISSIONS = new Set([ 'messages:send', 'engine:read', @@ -51,6 +57,14 @@ function validateManifest(id, manifest) { // folder, the zip name, the release tag. `manifest.id` is what the host installs under and what the // catalogue publishes. If the two disagree, the download URL points at one plugin and the installed // plugin calls itself another. + // PLUGIN-STANDARD states an id shape and a reserved list; nothing checked either, so both could be + // violated by a plugin that then took a path or a catalogue slot it should not have. + if (!ID_SHAPE.test(manifest.id ?? '')) { + throw new Error(`${id}: manifest.id "${manifest.id}" does not match ${ID_SHAPE} (PLUGIN-STANDARD.md)`); + } + if (RESERVED_IDS.has(manifest.id)) { + throw new Error(`${id}: "${manifest.id}" is a reserved id`); + } if (manifest.id !== id) { throw new Error(`${id}: manifest.id is "${manifest.id}" — it must match the directory name`); } diff --git a/scripts/catalog.test.mjs b/scripts/catalog.test.mjs index baecf9e..9c97b67 100644 --- a/scripts/catalog.test.mjs +++ b/scripts/catalog.test.mjs @@ -65,6 +65,9 @@ test('every stable plugin ships a full i18n block', () => { // a plugin whose name and description translate while all of its config-field titles stay English — // voice-transcription's `es` did exactly that, and being `beta` also put it outside the stable-only // test above. +// Scope note: this compares config-field KEYS, which carry `title`. No plugin translates a field's +// `description`, so requiring it here would fail every locale in the catalogue at once. What is +// guaranteed is that a translated locale covers every field's title; the descriptions stay English. test('a locale that translates a plugin translates all of its config fields', () => { const root = new URL('../', import.meta.url); const dirs = readdirSync(root, { withFileTypes: true }) diff --git a/supabase-otp-hook/manifest.json b/supabase-otp-hook/manifest.json index 7540677..25fee98 100644 --- a/supabase-otp-hook/manifest.json +++ b/supabase-otp-hook/manifest.json @@ -13,7 +13,7 @@ "status": "beta", "minOpenWAVersion": "0.8.16", "testedOpenWAVersion": "0.14.0", - "sdkVersion": "1", + "sdkVersion": 1, "permissions": ["webhook:ingress", "messages:send"], "sessionScoped": true, "sessions": ["*"], diff --git a/typebot-connector/manifest.json b/typebot-connector/manifest.json index da75b25..740b964 100644 --- a/typebot-connector/manifest.json +++ b/typebot-connector/manifest.json @@ -13,7 +13,7 @@ "status": "stable", "minOpenWAVersion": "0.8.2", "testedOpenWAVersion": "0.14.0", - "sdkVersion": "1", + "sdkVersion": 1, "permissions": ["net:fetch", "conversation:send", "storage:use"], "net": { "allow": [], "allowConfigHosts": ["apiHost", "mediaHost"] }, diff --git a/types/openwa.d.ts b/types/openwa.d.ts index c23a5f9..e940d60 100644 --- a/types/openwa.d.ts +++ b/types/openwa.d.ts @@ -1,7 +1,9 @@ // Vendored OpenWA plugin contract. There is no published @openwa SDK package; keep this in sync // with the OpenWA version you target. All imports of this module must be `import type`. // -// Last aligned against OpenWA core v0.14.5 (tag), verified field-by-field against +// Last aligned against OpenWA core v0.14.5 (tag). Where a comment below mentions later host +// behaviour, it is describing a change this file has NOT been re-verified against — treat it as a +// note about the host's direction, not as something checked here., verified field-by-field against // src/core/plugins/plugin.interfaces.ts, src/core/hooks/hook.interfaces.ts, plugin-net.ts, // sandbox/{worker-bootstrap,worker-capability,worker-hooks,worker-webhooks}.ts and // src/engine/interfaces/whatsapp-engine.interface.ts. Where this file narrows the host on purpose it @@ -144,7 +146,7 @@ export interface PluginEngineReadCapability { } // ── v0.7: host-proxied, SSRF-guarded outbound HTTP ────────────────────────────────────────────── -// Gated by the "net:fetch" permission + manifest `net.allow` (host:port allowlist; deny by default). +// Gated by the "net:fetch" permission + manifest `net.allow` (host allowlist, port optional; deny by default). // Use this for ALL outbound HTTP — the raw worker `fetch` is unguarded and discouraged. export interface PluginNetRequestInit { method?: string; @@ -186,7 +188,8 @@ export interface PluginManifest { hooks?: HookEvent[]; /** v0.7: per-session activation (default true). The platform owns which sessions a plugin runs for. */ sessionScoped?: boolean; - /** v0.7: outbound HTTP host allowlist for ctx.net.fetch — "host:port" entries; deny by default. + /** v0.7: outbound HTTP host allowlist for ctx.net.fetch — "host" or "host:port"; deny by default. + * The catalogue uses both forms: a bare host matches any port, which is what most entries rely on. * v1: `allowConfigHosts` additionally admits the host of each named config key (e.g. "baseUrl"). */ net?: { allow: string[]; allowConfigHosts?: string[] }; /** v0.7: a sandboxed-iframe config editor served by the host. */ From 81771e6e5950dbdb78733349bd9177ed24e0381f Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Wed, 12 Aug 2026 18:08:55 +0700 Subject: [PATCH 2/2] fix: report a failing flush, and accept overnight and all-day schedules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsheets-logger reported healthy whenever a client existed. A spreadsheet that had stopped accepting appends therefore showed a green tile while rows piled up in the buffer — the one state an operator has to see. healthCheck now reports unhealthy while a flush is failing and names the last error. after-hours rejected any window whose open was not before its close, so "22:00-06:00" and "00:00-00:00" were refused — and because the parser throws, one such day made the entire schedule unparseable. Both are ordinary business hours. The comparison now understands a wrapped window: open late and early, closed in between. A window whose open equals its close is still refused, with the all-day spelling named in the message. --- after-hours/CHANGELOG.md | 4 ++++ after-hours/schedule.test.ts | 14 +++++++++++++- after-hours/schedule.ts | 14 ++++++++++++-- gsheets-logger/CHANGELOG.md | 3 +++ gsheets-logger/index.ts | 11 ++++++++++- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/after-hours/CHANGELOG.md b/after-hours/CHANGELOG.md index 359836e..412dfe0 100644 --- a/after-hours/CHANGELOG.md +++ b/after-hours/CHANGELOG.md @@ -10,6 +10,10 @@ The version here always matches `manifest.json`'s `version`. ### Fixed +- **Overnight and all-day windows are accepted.** `22:00-06:00` and `00:00-00:00` are ordinary business + hours, but the parser rejected any window whose open was not before its close — which made the whole + schedule unparseable rather than just that day. The comparison understands a wrapped window now, so an + overnight one is open late and early and closed in between. - **The send-retry backoff map is now bounded.** An entry is added when a send fails and removed on the next delivery, so a chat that never messages again left one behind. Every other piece of state in this plugin was already capped; this was the exception. diff --git a/after-hours/schedule.test.ts b/after-hours/schedule.test.ts index a331498..45be0d6 100644 --- a/after-hours/schedule.test.ts +++ b/after-hours/schedule.test.ts @@ -15,7 +15,6 @@ test('parseSchedule rejects bad input', () => { assert.throws(() => parseSchedule('[]'), /object/i); assert.throws(() => parseSchedule(JSON.stringify({ xyz: '09:00-17:00' })), /unknown day/i); assert.throws(() => parseSchedule(JSON.stringify({ mon: '9:00-17:00' })), /HH:MM/i); - assert.throws(() => parseSchedule(JSON.stringify({ mon: '17:00-09:00' })), /before close/i); assert.throws(() => parseSchedule(JSON.stringify({ mon: null, sun: null })), /no open days/i); assert.throws(() => parseSchedule(JSON.stringify({ mon: '09:00-17:00-junk' })), /HH:MM/i); }); @@ -52,3 +51,16 @@ test('isAfterHours: closed day and the local-midnight edge', () => { // (also exercises the hour '24' → %24 normalization). assert.equal(isAfterHours(new Date('2026-06-21T17:00:00Z'), sched, 'Asia/Jakarta'), true); }); + +test('an overnight window and an all-day window are accepted and honoured', () => { + // "22:00-06:00" and "00:00-00:00" are ordinary business hours. The parser rejected both, which made + // the whole schedule unparseable rather than just that day, and the comparison only understood a + // window that opens and closes on the same date. + const sch = parseSchedule(JSON.stringify({ mon: '22:00-06:00', wed: '00:00-00:00' })); + const at = (d: number, h: number, m: number) => isAfterHours(new Date(Date.UTC(2026, 7, d, h, m)), sch, 'UTC'); + assert.equal(at(10, 23, 20), false, 'late evening is inside an overnight window'); + assert.equal(at(10, 5, 0), false, 'early morning is inside an overnight window'); + assert.equal(at(10, 10, 0), true, 'mid-morning is outside it'); + assert.equal(at(12, 3, 0), false, '00:00-00:00 is open all day'); + assert.throws(() => parseSchedule(JSON.stringify({ mon: '09:00-09:00' })), /use 00:00-00:00/); +}); diff --git a/after-hours/schedule.ts b/after-hours/schedule.ts index 4e89735..9eedd07 100644 --- a/after-hours/schedule.ts +++ b/after-hours/schedule.ts @@ -43,7 +43,12 @@ export function parseSchedule(json: string): Schedule { if (openMin === null || closeMin === null) { throw new Error(`schedule: ${day} window "${value}" is not "HH:MM-HH:MM"`); } - if (openMin >= closeMin) throw new Error(`schedule: ${day} open must be before close ("${value}")`); + // An overnight window ("22:00-06:00") and a 24-hour one ("00:00-00:00") are both ordinary business + // hours; rejecting them made the whole schedule unparseable rather than just that day. They are + // stored as-is and interpreted by the comparison, which already handles a wrapped window. + if (openMin === closeMin && value.trim() !== '00:00-00:00') { + throw new Error(`schedule: ${day} open and close are the same ("${value}") — use 00:00-00:00 for all day`); + } schedule[day] = { openMin, closeMin }; } @@ -74,5 +79,10 @@ export function isAfterHours(date: Date, schedule: Schedule, timezone: string): const minutes = (Number(get('hour')) % 24) * 60 + Number(get('minute')); const window = day ? schedule[day] : undefined; if (!window) return true; // closed day (or an unmapped weekday — treat as closed) - return minutes < window.openMin || minutes >= window.closeMin; + // "00:00-00:00" means open all day. + if (window.openMin === window.closeMin) return false; + // A normal window opens and closes on the same day; a wrapped one ("22:00-06:00") opens in the + // evening and closes the next morning, so "inside" is late OR early rather than between. + if (window.openMin < window.closeMin) return minutes < window.openMin || minutes >= window.closeMin; + return minutes < window.openMin && minutes >= window.closeMin; } diff --git a/gsheets-logger/CHANGELOG.md b/gsheets-logger/CHANGELOG.md index 367461e..2466d34 100644 --- a/gsheets-logger/CHANGELOG.md +++ b/gsheets-logger/CHANGELOG.md @@ -10,6 +10,9 @@ The version here always matches `manifest.json`'s `version`. ### Fixed +- **healthCheck now reports unhealthy while a flush is failing.** The plugin was green whenever a client + existed, so a spreadsheet that had stopped accepting appends looked fine while rows piled up. The last + flush error is surfaced alongside the buffered-row count. - **Undelivered rows no longer follow a spreadsheet rotation.** `onConfigChange` drains before swapping the client, but a drain that cannot reach Sheets leaves every row in place — and those rows carry message content captured under the previous spreadsheet and credentials. They are now parked under diff --git a/gsheets-logger/index.ts b/gsheets-logger/index.ts index 489642d..91af9cb 100644 --- a/gsheets-logger/index.ts +++ b/gsheets-logger/index.ts @@ -103,6 +103,9 @@ export default class GSheetsLogger implements IPlugin { private flushingPromise: Promise | null = null; private ctx: PluginContext | null = null; private batchSize = 20; + // Last flush failure, surfaced on healthCheck. Green while every append fails is the one state an + // operator must not see: the rows are piling up and nothing says so. + private lastFlushError: string | null = null; async onEnable(ctx: PluginContext): Promise { this.ctx = ctx; @@ -190,7 +193,11 @@ export default class GSheetsLogger implements IPlugin { } async healthCheck(): Promise<{ healthy: boolean; message?: string }> { - return { healthy: this.client !== null, message: `v${PLUGIN_VERSION} — ${this.buffer.length} rows buffered` }; + const parts = [`v${PLUGIN_VERSION}`, `${this.buffer.length} rows buffered`]; + if (this.lastFlushError) parts.push(`last flush failed: ${this.lastFlushError.slice(0, 200)}`); + // Unhealthy while a flush is failing: the plugin is running, but nothing it was installed to do is + // reaching the sheet, and a green tile is what let that go unnoticed. + return { healthy: this.client !== null && this.lastFlushError === null, message: parts.join(' — ') }; } private startTimer(intervalSec: number): void { @@ -226,8 +233,10 @@ export default class GSheetsLogger implements IPlugin { this.flushingPromise = (async () => { try { await flushBuffer(this.buffer, (rows) => client.appendRows(rows)); + this.lastFlushError = null; await this.ctx?.storage.set(BUFFER_KEY, this.buffer); } catch (err) { + this.lastFlushError = err instanceof Error ? err.message : String(err); this.ctx?.logger.error('gsheets-logger: flush failed, will retry next tick', err); } finally { this.flushingPromise = null;