@rei-standard/amsg-server 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,10 @@ const rei = await createReiServer({
50
50
  // GET /api/v1/messages -> rei.handlers.messages.GET
51
51
  ```
52
52
 
53
+ ## 关于 `messageType: 'instant'`
54
+
55
+ > **Note**:新代码的 instant 消息请用 [@rei-standard/amsg-instant](../instant/README.md),跳过本端点的"建任务 → 处理 → 删任务" DB 来回。本端点的 `instant` 分支为兼容保留,行为不变、不会有运行时警告。
56
+
53
57
  ## AI 接口 `apiUrl` 约束
54
58
 
55
59
  当 `messageType` 为 `prompted` / `auto`,或 `instant` 使用 AI 配置时:
package/dist/index.cjs CHANGED
@@ -100,7 +100,7 @@ function validateScheduleMessagePayload(payload) {
100
100
  if (payload.uuid && !isValidUUID(payload.uuid)) {
101
101
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
102
102
  }
103
- if (payload.messageSubtype && !["chat", "forum", "moment"].includes(payload.messageSubtype)) {
103
+ if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
104
104
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
105
105
  }
106
106
  return { valid: true };
@@ -554,7 +554,7 @@ function buildAiRequestBody(payload) {
554
554
  function normalizeAiApiUrl(apiUrl) {
555
555
  if (typeof apiUrl !== "string" || !apiUrl.trim()) {
556
556
  throw new Error(
557
- "Invalid apiUrl: apiUrl is required. Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions)."
557
+ "Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
558
558
  );
559
559
  }
560
560
  const trimmedApiUrl = apiUrl.trim();
@@ -563,10 +563,17 @@ function normalizeAiApiUrl(apiUrl) {
563
563
  parsedUrl = new URL(trimmedApiUrl);
564
564
  } catch (e3) {
565
565
  throw new Error(
566
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL that points to a full chat endpoint.`
566
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
567
567
  );
568
568
  }
569
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "") || "/";
569
+ let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
570
+ if (/\/chat\/completions$/.test(path)) {
571
+ } else if (path === "/") {
572
+ path = "/v1/chat/completions";
573
+ } else if (/\/v\d+$/.test(path)) {
574
+ path = `${path}/chat/completions`;
575
+ }
576
+ parsedUrl.pathname = path;
570
577
  return parsedUrl.toString();
571
578
  }
572
579
 
@@ -1235,15 +1242,21 @@ function verifyTenantToken(token, secret, options = {}) {
1235
1242
  const [encodedHeader, encodedPayload, receivedSignature] = parts;
1236
1243
  const signingInput = `${encodedHeader}.${encodedPayload}`;
1237
1244
  const expectedSignature = sign(signingInput, secret);
1238
- const receivedBuffer = Buffer.from(receivedSignature);
1239
- const expectedBuffer = Buffer.from(expectedSignature);
1245
+ let receivedBuffer;
1246
+ let expectedBuffer;
1247
+ try {
1248
+ receivedBuffer = base64UrlDecode2(receivedSignature);
1249
+ expectedBuffer = base64UrlDecode2(expectedSignature);
1250
+ } catch (e4) {
1251
+ throw new Error("INVALID_TENANT_AUTH");
1252
+ }
1240
1253
  if (receivedBuffer.length !== expectedBuffer.length || !_crypto.timingSafeEqual.call(void 0, receivedBuffer, expectedBuffer)) {
1241
1254
  throw new Error("INVALID_TENANT_AUTH");
1242
1255
  }
1243
1256
  let payload;
1244
1257
  try {
1245
1258
  payload = JSON.parse(base64UrlDecode2(encodedPayload).toString("utf8"));
1246
- } catch (e4) {
1259
+ } catch (e5) {
1247
1260
  throw new Error("INVALID_TENANT_AUTH");
1248
1261
  }
1249
1262
  if (!payload || typeof payload !== "object" || payload.v !== 1) {
@@ -1300,7 +1313,7 @@ function parseTokenFromUrl(url) {
1300
1313
  try {
1301
1314
  const parsed = new URL(url, "https://dummy.local");
1302
1315
  return String(parsed.searchParams.get("token") || "").trim();
1303
- } catch (e5) {
1316
+ } catch (e6) {
1304
1317
  return "";
1305
1318
  }
1306
1319
  }
package/dist/index.d.cts CHANGED
@@ -174,7 +174,14 @@ function validateScheduleMessagePayload(payload) {
174
174
  if (payload.uuid && !isValidUUID(payload.uuid)) {
175
175
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
176
176
  }
177
- if (payload.messageSubtype && !['chat', 'forum', 'moment'].includes(payload.messageSubtype)) {
177
+ // messageSubtype is a free-form string tag forwarded to SW-side push payload
178
+ // for classification. Only the type is enforced; the taxonomy is the
179
+ // consumer's call (previously the enum was chat/forum/moment).
180
+ if (
181
+ payload.messageSubtype !== undefined &&
182
+ payload.messageSubtype !== null &&
183
+ typeof payload.messageSubtype !== 'string'
184
+ ) {
178
185
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
179
186
  }
180
187
 
@@ -899,12 +906,27 @@ function buildAiRequestBody(payload) {
899
906
  }
900
907
 
901
908
  /**
902
- * Normalize AI API URL with minimal, non-breaking rules:
903
- * - trim whitespace
904
- * - remove redundant trailing slashes from pathname
905
- * - keep query string untouched
909
+ * Normalize AI API URL for OpenAI-compatible chat endpoints.
910
+ *
911
+ * **Keep in sync** with `@rei-standard/amsg-instant`'s
912
+ * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
913
+ * tests. The two packages share this logic but each carry their own copy
914
+ * to avoid an architectural dependency (server should not depend on the
915
+ * stateless worker package).
906
916
  *
907
- * Note: This function does NOT auto-append /v1 or /chat/completions.
917
+ * Rules (idempotent — running it twice equals running it once):
918
+ * - Already ends with `/chat/completions` → leave as-is.
919
+ * - Bare host (no path or just `/`) → append `/v1/chat/completions`.
920
+ * - Path ends with a version segment like `/v1`,
921
+ * `/v2`, … (with or without trailing slash) → append only
922
+ * `/chat/completions`; never doubles `/v1` for
923
+ * callers who already include it.
924
+ * - Anything else (custom path that doesn't match the
925
+ * OpenAI shape, e.g. `/v1/messages` for
926
+ * Anthropic-style proxies) → leave as-is. The caller
927
+ * knows their own routing.
928
+ *
929
+ * Query string is preserved verbatim.
908
930
  *
909
931
  * @param {string} apiUrl
910
932
  * @returns {string}
@@ -913,7 +935,8 @@ function normalizeAiApiUrl(apiUrl) {
913
935
  if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
914
936
  throw new Error(
915
937
  'Invalid apiUrl: apiUrl is required. ' +
916
- 'Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions).'
938
+ 'Please provide a chat endpoint URL ' +
939
+ '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
917
940
  );
918
941
  }
919
942
 
@@ -924,12 +947,23 @@ function normalizeAiApiUrl(apiUrl) {
924
947
  parsedUrl = new URL(trimmedApiUrl);
925
948
  } catch {
926
949
  throw new Error(
927
- `Invalid apiUrl: "${apiUrl}". ` +
928
- 'Please provide a valid absolute URL that points to a full chat endpoint.'
950
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
929
951
  );
930
952
  }
931
953
 
932
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
954
+ let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
955
+
956
+ if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
957
+ // Bare host → assume OpenAI shape.
958
+ path = '/v1/chat/completions';
959
+ } else if (/\/v\d+$/.test(path)) {
960
+ // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
961
+ // Append only `/chat/completions`; never re-add `/v1`.
962
+ path = `${path}/chat/completions`;
963
+ }
964
+ // Any other custom path is left untouched on purpose.
965
+
966
+ parsedUrl.pathname = path;
933
967
  return parsedUrl.toString();
934
968
  }
935
969
 
@@ -1026,6 +1060,14 @@ function createScheduleMessageHandler(ctx) {
1026
1060
 
1027
1061
  const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
1028
1062
 
1063
+ /**
1064
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1065
+ * This branch is kept for backward compatibility and the existing behavior
1066
+ * (create task → process → delete) is unchanged. The dedicated amsg-instant
1067
+ * package is stateless (no DB roundtrip), deployable to Cloudflare Workers,
1068
+ * and locks the encryption + push-payload contract behind a single version.
1069
+ * See packages/rei-standard-amsg/instant/README.md.
1070
+ */
1029
1071
  // Instant type: check VAPID before creating the task to avoid orphaned rows
1030
1072
  if (payload.messageType === 'instant') {
1031
1073
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
@@ -1079,6 +1121,13 @@ function createScheduleMessageHandler(ctx) {
1079
1121
  return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
1080
1122
  }
1081
1123
 
1124
+ /**
1125
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1126
+ * The "create-task → process-by-uuid → delete-task" sequence below is
1127
+ * preserved verbatim so existing clients keep working. New integrations
1128
+ * should call the dedicated amsg-instant endpoint instead — it skips this
1129
+ * DB round-trip entirely. See packages/rei-standard-amsg/instant/README.md.
1130
+ */
1082
1131
  // Instant type: send immediately
1083
1132
  if (payload.messageType === 'instant') {
1084
1133
  try {
@@ -1792,8 +1841,17 @@ function verifyTenantToken(token, secret, options = {}) {
1792
1841
  const signingInput = `${encodedHeader}.${encodedPayload}`;
1793
1842
  const expectedSignature = sign(signingInput, secret);
1794
1843
 
1795
- const receivedBuffer = Buffer.from(receivedSignature);
1796
- const expectedBuffer = Buffer.from(expectedSignature);
1844
+ // Compare raw HMAC bytes (decoded from base64url) rather than the
1845
+ // encoded character bytes — semantically clearer and avoids the
1846
+ // implicit-UTF-8 default of Buffer.from(string).
1847
+ let receivedBuffer;
1848
+ let expectedBuffer;
1849
+ try {
1850
+ receivedBuffer = base64UrlDecode(receivedSignature);
1851
+ expectedBuffer = base64UrlDecode(expectedSignature);
1852
+ } catch {
1853
+ throw new Error('INVALID_TENANT_AUTH');
1854
+ }
1797
1855
  if (
1798
1856
  receivedBuffer.length !== expectedBuffer.length ||
1799
1857
  !timingSafeEqual(receivedBuffer, expectedBuffer)
package/dist/index.d.ts CHANGED
@@ -174,7 +174,14 @@ function validateScheduleMessagePayload(payload) {
174
174
  if (payload.uuid && !isValidUUID(payload.uuid)) {
175
175
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
176
176
  }
177
- if (payload.messageSubtype && !['chat', 'forum', 'moment'].includes(payload.messageSubtype)) {
177
+ // messageSubtype is a free-form string tag forwarded to SW-side push payload
178
+ // for classification. Only the type is enforced; the taxonomy is the
179
+ // consumer's call (previously the enum was chat/forum/moment).
180
+ if (
181
+ payload.messageSubtype !== undefined &&
182
+ payload.messageSubtype !== null &&
183
+ typeof payload.messageSubtype !== 'string'
184
+ ) {
178
185
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
179
186
  }
180
187
 
@@ -899,12 +906,27 @@ function buildAiRequestBody(payload) {
899
906
  }
900
907
 
901
908
  /**
902
- * Normalize AI API URL with minimal, non-breaking rules:
903
- * - trim whitespace
904
- * - remove redundant trailing slashes from pathname
905
- * - keep query string untouched
909
+ * Normalize AI API URL for OpenAI-compatible chat endpoints.
910
+ *
911
+ * **Keep in sync** with `@rei-standard/amsg-instant`'s
912
+ * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
913
+ * tests. The two packages share this logic but each carry their own copy
914
+ * to avoid an architectural dependency (server should not depend on the
915
+ * stateless worker package).
906
916
  *
907
- * Note: This function does NOT auto-append /v1 or /chat/completions.
917
+ * Rules (idempotent — running it twice equals running it once):
918
+ * - Already ends with `/chat/completions` → leave as-is.
919
+ * - Bare host (no path or just `/`) → append `/v1/chat/completions`.
920
+ * - Path ends with a version segment like `/v1`,
921
+ * `/v2`, … (with or without trailing slash) → append only
922
+ * `/chat/completions`; never doubles `/v1` for
923
+ * callers who already include it.
924
+ * - Anything else (custom path that doesn't match the
925
+ * OpenAI shape, e.g. `/v1/messages` for
926
+ * Anthropic-style proxies) → leave as-is. The caller
927
+ * knows their own routing.
928
+ *
929
+ * Query string is preserved verbatim.
908
930
  *
909
931
  * @param {string} apiUrl
910
932
  * @returns {string}
@@ -913,7 +935,8 @@ function normalizeAiApiUrl(apiUrl) {
913
935
  if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
914
936
  throw new Error(
915
937
  'Invalid apiUrl: apiUrl is required. ' +
916
- 'Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions).'
938
+ 'Please provide a chat endpoint URL ' +
939
+ '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
917
940
  );
918
941
  }
919
942
 
@@ -924,12 +947,23 @@ function normalizeAiApiUrl(apiUrl) {
924
947
  parsedUrl = new URL(trimmedApiUrl);
925
948
  } catch {
926
949
  throw new Error(
927
- `Invalid apiUrl: "${apiUrl}". ` +
928
- 'Please provide a valid absolute URL that points to a full chat endpoint.'
950
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
929
951
  );
930
952
  }
931
953
 
932
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
954
+ let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
955
+
956
+ if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
957
+ // Bare host → assume OpenAI shape.
958
+ path = '/v1/chat/completions';
959
+ } else if (/\/v\d+$/.test(path)) {
960
+ // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
961
+ // Append only `/chat/completions`; never re-add `/v1`.
962
+ path = `${path}/chat/completions`;
963
+ }
964
+ // Any other custom path is left untouched on purpose.
965
+
966
+ parsedUrl.pathname = path;
933
967
  return parsedUrl.toString();
934
968
  }
935
969
 
@@ -1026,6 +1060,14 @@ function createScheduleMessageHandler(ctx) {
1026
1060
 
1027
1061
  const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
1028
1062
 
1063
+ /**
1064
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1065
+ * This branch is kept for backward compatibility and the existing behavior
1066
+ * (create task → process → delete) is unchanged. The dedicated amsg-instant
1067
+ * package is stateless (no DB roundtrip), deployable to Cloudflare Workers,
1068
+ * and locks the encryption + push-payload contract behind a single version.
1069
+ * See packages/rei-standard-amsg/instant/README.md.
1070
+ */
1029
1071
  // Instant type: check VAPID before creating the task to avoid orphaned rows
1030
1072
  if (payload.messageType === 'instant') {
1031
1073
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
@@ -1079,6 +1121,13 @@ function createScheduleMessageHandler(ctx) {
1079
1121
  return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
1080
1122
  }
1081
1123
 
1124
+ /**
1125
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1126
+ * The "create-task → process-by-uuid → delete-task" sequence below is
1127
+ * preserved verbatim so existing clients keep working. New integrations
1128
+ * should call the dedicated amsg-instant endpoint instead — it skips this
1129
+ * DB round-trip entirely. See packages/rei-standard-amsg/instant/README.md.
1130
+ */
1082
1131
  // Instant type: send immediately
1083
1132
  if (payload.messageType === 'instant') {
1084
1133
  try {
@@ -1792,8 +1841,17 @@ function verifyTenantToken(token, secret, options = {}) {
1792
1841
  const signingInput = `${encodedHeader}.${encodedPayload}`;
1793
1842
  const expectedSignature = sign(signingInput, secret);
1794
1843
 
1795
- const receivedBuffer = Buffer.from(receivedSignature);
1796
- const expectedBuffer = Buffer.from(expectedSignature);
1844
+ // Compare raw HMAC bytes (decoded from base64url) rather than the
1845
+ // encoded character bytes — semantically clearer and avoids the
1846
+ // implicit-UTF-8 default of Buffer.from(string).
1847
+ let receivedBuffer;
1848
+ let expectedBuffer;
1849
+ try {
1850
+ receivedBuffer = base64UrlDecode(receivedSignature);
1851
+ expectedBuffer = base64UrlDecode(expectedSignature);
1852
+ } catch {
1853
+ throw new Error('INVALID_TENANT_AUTH');
1854
+ }
1797
1855
  if (
1798
1856
  receivedBuffer.length !== expectedBuffer.length ||
1799
1857
  !timingSafeEqual(receivedBuffer, expectedBuffer)
package/dist/index.mjs CHANGED
@@ -100,7 +100,7 @@ function validateScheduleMessagePayload(payload) {
100
100
  if (payload.uuid && !isValidUUID(payload.uuid)) {
101
101
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
102
102
  }
103
- if (payload.messageSubtype && !["chat", "forum", "moment"].includes(payload.messageSubtype)) {
103
+ if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
104
104
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
105
105
  }
106
106
  return { valid: true };
@@ -554,7 +554,7 @@ function buildAiRequestBody(payload) {
554
554
  function normalizeAiApiUrl(apiUrl) {
555
555
  if (typeof apiUrl !== "string" || !apiUrl.trim()) {
556
556
  throw new Error(
557
- "Invalid apiUrl: apiUrl is required. Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions)."
557
+ "Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
558
558
  );
559
559
  }
560
560
  const trimmedApiUrl = apiUrl.trim();
@@ -563,10 +563,17 @@ function normalizeAiApiUrl(apiUrl) {
563
563
  parsedUrl = new URL(trimmedApiUrl);
564
564
  } catch {
565
565
  throw new Error(
566
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL that points to a full chat endpoint.`
566
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
567
567
  );
568
568
  }
569
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "") || "/";
569
+ let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
570
+ if (/\/chat\/completions$/.test(path)) {
571
+ } else if (path === "/") {
572
+ path = "/v1/chat/completions";
573
+ } else if (/\/v\d+$/.test(path)) {
574
+ path = `${path}/chat/completions`;
575
+ }
576
+ parsedUrl.pathname = path;
570
577
  return parsedUrl.toString();
571
578
  }
572
579
 
@@ -1235,8 +1242,14 @@ function verifyTenantToken(token, secret, options = {}) {
1235
1242
  const [encodedHeader, encodedPayload, receivedSignature] = parts;
1236
1243
  const signingInput = `${encodedHeader}.${encodedPayload}`;
1237
1244
  const expectedSignature = sign(signingInput, secret);
1238
- const receivedBuffer = Buffer.from(receivedSignature);
1239
- const expectedBuffer = Buffer.from(expectedSignature);
1245
+ let receivedBuffer;
1246
+ let expectedBuffer;
1247
+ try {
1248
+ receivedBuffer = base64UrlDecode2(receivedSignature);
1249
+ expectedBuffer = base64UrlDecode2(expectedSignature);
1250
+ } catch {
1251
+ throw new Error("INVALID_TENANT_AUTH");
1252
+ }
1240
1253
  if (receivedBuffer.length !== expectedBuffer.length || !timingSafeEqual(receivedBuffer, expectedBuffer)) {
1241
1254
  throw new Error("INVALID_TENANT_AUTH");
1242
1255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-server",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "ReiStandard Active Messaging server SDK with pluggable database adapters",
5
5
  "repository": {
6
6
  "type": "git",