@ornncompute/cli 1.0.34 → 1.0.48

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.
@@ -46,22 +46,16 @@ export const listingCreateInput = z.object({
46
46
  .describe("End of the availability window. YYYY-MM-DD or RFC3339."),
47
47
  available_from: z.string().optional().describe("Alias for available_start_at."),
48
48
  available_to: z.string().optional().describe("Alias for available_end_at."),
49
- buy_now_price_per_gpu_hour: z
50
- .number()
51
- .optional()
52
- .describe("Price buyers pay, USD per GPU-hour. The spread over cost_per_gpu_hour is Ornn's."),
53
- cost_per_gpu_hour: z
54
- .number()
55
- .optional()
56
- .describe("Supplier price, USD per GPU-hour. Defaults to the buy-now price (spread 0)."),
49
+ buy_now_price_per_gpu_hour: z.number().optional().describe("Price buyers pay, USD per GPU-hour."),
57
50
  deposit_percent: z.number().optional(),
58
51
  down_payment_cents: z.number().int().nonnegative().optional(),
59
52
  access_mode: z.enum(["bare-metal", "virtual-machine"]).optional(),
60
- state: z.enum(["available", "internal"]).optional(),
53
+ state: z.enum(["available", "draft"]).optional(),
61
54
  price_omitted: z.boolean().optional(),
62
55
  announce: z.boolean().optional(),
63
56
  text: z.string().optional(),
64
57
  confirm: z.boolean().optional(),
58
+ idempotencyKey: z.string().min(1).max(200).optional(),
65
59
  });
66
60
  const catalogTermsInput = z.object({ kind: z.enum(["gpu", "cpu", "node", "link"]).optional() });
67
61
  const slackListingCreateParams = {
@@ -80,7 +74,6 @@ const slackListingCreateParams = {
80
74
  available_start_at: { type: "string" },
81
75
  available_end_at: { type: "string" },
82
76
  buy_now_price_per_gpu_hour: { type: "number" },
83
- cost_per_gpu_hour: { type: "number" },
84
77
  deposit_percent: { type: "number" },
85
78
  price_omitted: { type: "boolean" },
86
79
  announce: { type: "boolean" },
@@ -166,6 +159,23 @@ export function primaryDownPaymentCents(terms) {
166
159
  const totalCents = (terms.pricePerGpuHourMicroUsd * terms.nodeCount * terms.gpusPerNode * hours) / 10_000;
167
160
  return Math.max(0, Math.round((totalCents * terms.percent) / 100));
168
161
  }
162
+ /** Shared native creation body for Slack's preview and human-confirmed write. */
163
+ export function primaryListingBody(input, pricePerGpuHourMicroUsd, downPaymentCents) {
164
+ return {
165
+ ...input,
166
+ kind: "primary",
167
+ terms: [
168
+ {
169
+ type: "reserved",
170
+ minDurationHours: 1 / 3600,
171
+ maxDurationHours: (Date.parse(input.availableUntil) - Date.parse(input.availableAt)) / 3600000,
172
+ renewable: false,
173
+ pricePerGpuHourMicroUsd,
174
+ downPaymentCents,
175
+ },
176
+ ],
177
+ };
178
+ }
169
179
  /** Commerce parses listing windows as RFC3339 timestamps, not bare dates. */
170
180
  function rfc3339(value) {
171
181
  const trimmed = value.trim();
@@ -193,13 +203,13 @@ const LISTING_CREATE_INPUT_ONLY_KEYS = [
193
203
  "site",
194
204
  "kind",
195
205
  "confirm",
206
+ "idempotencyKey",
196
207
  "organizationId",
197
208
  "organization_id",
198
209
  "site_supplier",
199
210
  "facilityId",
200
211
  "facility_id",
201
212
  "buy_now_price_per_gpu_hour",
202
- "cost_per_gpu_hour",
203
213
  "deposit_percent",
204
214
  "down_payment_cents",
205
215
  "access_mode",
@@ -208,12 +218,13 @@ const LISTING_CREATE_INPUT_ONLY_KEYS = [
208
218
  ];
209
219
  export const createListingCapability = defineCapability({
210
220
  id: "listings.create",
221
+ mcp: false,
211
222
  domain: "listings",
212
223
  roles: ["admin"],
213
224
  description: "Create a primary marketplace listing (admin). gpu_type, cpu, node_model, and fabric_type must exact-match an orchestrator catalog display name, canonical, or alias; gpu_type becomes the listing's GPU slug. " +
214
- "cost_per_gpu_hour is the supplier price and buy_now_price_per_gpu_hour the price buyers pay; the difference is Ornn's spread. Nodes are attached afterwards. CLI asks y/N. Slack never POSTs until a human replies confirm. MCP creates on this tool call.",
225
+ "buy_now_price_per_gpu_hour is the customer price; deposit_percent is the upfront payment as a percentage of the full availability window. Nodes are attached afterwards. Slack never POSTs until a human replies confirm. MCP and CLI use native listing.create instead.",
215
226
  mutation: "preview-confirm",
216
- http: { method: "POST", path: "/commerce/listings" },
227
+ http: { method: "POST", path: "/commerce/v2/listings" },
217
228
  slack: { parameters: slackListingCreateParams },
218
229
  input: listingCreateInput,
219
230
  execute: async (ctx, input) => {
@@ -244,14 +255,21 @@ export const createListingCapability = defineCapability({
244
255
  const seenCursors = new Set();
245
256
  let cursor;
246
257
  do {
247
- const response = (await ctx.fetch(queryPath("/provisioning/facilities", { cursor }), { method: "GET" }));
258
+ const response = (await ctx.fetch(queryPath("/provisioning/facilities", { cursor }), {
259
+ method: "GET",
260
+ }));
248
261
  if (!Array.isArray(response.facilities))
249
262
  throw new Error("invalid facility catalog");
250
- matches.push(...response.facilities.filter((facility) => (!organizationId || (facility.organizationId ?? facility.organization_id) === organizationId) &&
263
+ matches.push(...response.facilities.filter((facility) => (!organizationId ||
264
+ (facility.organizationId ?? facility.organization_id) === organizationId) &&
251
265
  (facilityId
252
266
  ? (facility.facilityId ?? facility.id) === facilityId
253
- : !facilityQuery || [facility.facilityId ?? facility.id, facility.slug, facility.displayName ?? facility.display_name]
254
- .some((value) => value?.toLowerCase() === facilityQuery))));
267
+ : !facilityQuery ||
268
+ [
269
+ facility.facilityId ?? facility.id,
270
+ facility.slug,
271
+ facility.displayName ?? facility.display_name,
272
+ ].some((value) => value?.toLowerCase() === facilityQuery))));
255
273
  cursor = response.nextCursor ?? undefined;
256
274
  if (cursor) {
257
275
  if (seenCursors.has(cursor) || seenCursors.size >= 49) {
@@ -274,12 +292,12 @@ export const createListingCapability = defineCapability({
274
292
  const startsAt = rfc3339(parsed.available_start_at ??
275
293
  parsed.available_from ??
276
294
  parsed.starts_after ??
277
- new Date().toISOString());
295
+ new Date(Math.floor(Date.now() / 1000) * 1000).toISOString());
278
296
  const endsAt = rfc3339(parsed.available_end_at ??
279
297
  parsed.available_to ??
280
298
  parsed.ends_before ??
281
299
  parsed.ends_after ??
282
- new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString());
300
+ new Date(Math.floor(Date.now() / 1000) * 1000 + 30 * 24 * 60 * 60 * 1000).toISOString());
283
301
  const nodeCount = parsed.node_count ?? parsed.min_nodes ?? 1;
284
302
  const gpusPerNode = parsed.gpus_per_node ?? 8;
285
303
  const boundPayload = bound.payload;
@@ -288,12 +306,7 @@ export const createListingCapability = defineCapability({
288
306
  ? resolveCatalogTerm(hardware.terms, "gpu", normalized.gpu_type).canonical
289
307
  : null;
290
308
  const buyerPrice = parsed.buy_now_price_per_gpu_hour;
291
- const supplierPrice = parsed.cost_per_gpu_hour ?? buyerPrice;
292
- const supplierMicro = supplierPrice != null ? microUsd(supplierPrice) : null;
293
- const buyerMicro = buyerPrice != null ? microUsd(buyerPrice) : supplierMicro;
294
- if (supplierMicro != null && buyerMicro != null && buyerMicro < supplierMicro) {
295
- return { ok: false, error: "price_below_supplier_price" };
296
- }
309
+ const buyerMicro = buyerPrice != null ? microUsd(buyerPrice) : null;
297
310
  const downPaymentCents = parsed.down_payment_cents ??
298
311
  (parsed.deposit_percent != null && buyerMicro != null
299
312
  ? primaryDownPaymentCents({
@@ -305,26 +318,23 @@ export const createListingCapability = defineCapability({
305
318
  percent: parsed.deposit_percent,
306
319
  })
307
320
  : 0);
308
- const listing = organizationId && facilityId && gpuSlug && supplierMicro != null && buyerMicro != null
309
- ? {
321
+ const listing = organizationId && facilityId && gpuSlug && buyerMicro != null
322
+ ? primaryListingBody({
310
323
  organizationId,
311
324
  facilityId,
312
325
  gpuSlug,
313
326
  gpusPerNode,
314
- accessMode: parsed.access_mode ?? "virtual-machine",
315
- startsAt,
316
- endsAt,
327
+ provisioningType: parsed.access_mode ?? "virtual-machine",
328
+ availableAt: startsAt,
329
+ availableUntil: endsAt,
317
330
  ...(typeof draft.cpu === "string" ? { cpu: draft.cpu } : {}),
318
331
  ...(typeof draft.ram === "string" ? { ram: draft.ram } : {}),
319
332
  ...(typeof draft.storage === "string" ? { storage: draft.storage } : {}),
320
333
  ...(typeof draft.fabric_type === "string" ? { fabricType: draft.fabric_type } : {}),
321
334
  ...(typeof draft.internet === "string" ? { internet: draft.internet } : {}),
322
- ...(parsed.node_count ? { plannedNodeCount: parsed.node_count } : {}),
323
- supplierPricePerGpuHourMicroUsd: supplierMicro,
324
- spreadPerGpuHourMicroUsd: buyerMicro - supplierMicro,
325
- downPaymentCents,
326
- ...(parsed.state ? { state: parsed.state } : {}),
327
- }
335
+ capacityNodeCount: nodeCount,
336
+ state: parsed.state ?? "available",
337
+ }, buyerMicro, downPaymentCents)
328
338
  : null;
329
339
  if (!ctx.confirmed) {
330
340
  return preview("create_listing", {
@@ -337,10 +347,20 @@ export const createListingCapability = defineCapability({
337
347
  if (!listing) {
338
348
  return {
339
349
  ok: false,
340
- error: !organizationId || !facilityId ? "facility_required" : gpuSlug ? "price_required" : "gpu_required",
350
+ error: !organizationId || !facilityId
351
+ ? "facility_required"
352
+ : gpuSlug
353
+ ? "price_required"
354
+ : "gpu_required",
341
355
  };
342
356
  }
343
- return ctx.fetch("/commerce/listings", { method: "POST", body: listing });
357
+ if (!parsed.idempotencyKey)
358
+ return { ok: false, error: "idempotency_key_required" };
359
+ return ctx.fetch("/commerce/v2/listings", {
360
+ method: "POST",
361
+ body: listing,
362
+ headers: { "Idempotency-Key": parsed.idempotencyKey },
363
+ });
344
364
  },
345
365
  });
346
366
  export const listingCapabilities = [
@@ -84,9 +84,9 @@ export const spotBidInput = z
84
84
  gpus: z.coerce
85
85
  .number()
86
86
  .int()
87
- .min(1)
87
+ .min(8)
88
88
  .max(8)
89
- .describe("GPUs on one node, 1-8. To buy N whole nodes, place N bids of 8 GPUs."),
89
+ .describe("Exactly 8 GPUs on one full node. To buy N nodes, place N bids."),
90
90
  orderType: z
91
91
  .enum(["limit", "market"])
92
92
  .default("limit")
@@ -105,14 +105,14 @@ export const spotBidCapability = defineCapability({
105
105
  id: "spot.bid",
106
106
  domain: "markets",
107
107
  roles: ["user"],
108
- description: "Place a spot bid for 1-8 GPUs on one node. A filled bid becomes a running spot reservation, " +
109
- "prepaid hourly from Ornn credits first and then the saved card; the side that takes a resting order pays the transaction fee. Requires confirm: true.",
108
+ description: "Place a spot bid for a full node with 8 GPUs. A filled bid becomes a running spot reservation, " +
109
+ "prepaid hourly from Ornn credits first and then the saved card. Book prices are what you pay; a limit is the most you pay per GPU-hour. Requires confirm: true.",
110
110
  mutation: "preview-confirm",
111
111
  http: { method: "POST", path: `${BASE}/spot/orders` },
112
112
  input: spotBidInput,
113
113
  cli: {
114
114
  command: ["spot", "bid"],
115
- vnext: help(["spot", "bid"], "Place one spot bid for 1-8 GPUs on a single node.", "ornn spot bid --gpu-slug <slug> --gpus <1-8> [--order-type limit|market] [--price-per-gpu-hour-micro-usd <n>]", "ornn spot bid --gpu-slug <slug> --gpus 8 --price-per-gpu-hour-micro-usd 2500000 --yes --format json", ["spot orders", "spot cancel"], 33, ["catalog GPU slug"]),
115
+ vnext: help(["spot", "bid"], "Place one spot bid for a full node with 8 GPUs.", "ornn spot bid --gpu-slug <slug> --gpus 8 [--order-type limit|market] [--price-per-gpu-hour-micro-usd <n>]", "ornn spot bid --gpu-slug <slug> --gpus 8 --price-per-gpu-hour-micro-usd 2500000 --yes --format json", ["spot orders", "spot cancel"], 33, ["catalog GPU slug"]),
116
116
  },
117
117
  slack: false,
118
118
  execute: async (ctx, input) => {
@@ -266,7 +266,7 @@ export const schedulePurchaseCapability = defineCapability({
266
266
  id: "schedules.purchase",
267
267
  domain: "markets",
268
268
  roles: ["user"],
269
- description: "Buy a window of a secondary (relisted) listing at its asking price, for every node on it. There is no offer: the window is yours once it is paid. You pay the asking price plus the Ornn trading fee up front, from Ornn credits and then your saved card; $10,000 or more is invoiced by wire. Requires confirm: true.",
269
+ description: "Buy a window of a secondary (relisted) listing at its asking price, for every node on it. There is no offer: the window is yours once it is paid. You pay the asking price up front, from Ornn credits and then your saved card; $10,000 or more is invoiced by wire. Requires confirm: true.",
270
270
  mutation: "preview-confirm",
271
271
  http: { method: "POST", path: `${BASE}/schedules/purchases` },
272
272
  input: schedulePurchaseInput,
@@ -293,7 +293,7 @@ export const schedulePurchaseCapability = defineCapability({
293
293
  { label: "Asking price", value: microUsdLabel(parsed.askPricePerGpuHourMicroUsd) },
294
294
  {
295
295
  label: "Payment",
296
- value: "Up front, with the Ornn trading fee on top: Ornn credits, then your saved card.",
296
+ value: "Up front: Ornn credits, then your saved card.",
297
297
  },
298
298
  ], body);
299
299
  if (gate)
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ import { flag } from "./market-shared.js";
3
+ export const nativeTermFields = {
4
+ type: z.enum(["reserved", "spot", "on-demand"]).nullable().optional(),
5
+ minDurationHours: z.coerce.number().nonnegative(),
6
+ maxDurationHours: z.coerce.number().nonnegative(),
7
+ pricePerGpuHourMicroUsd: z.coerce.number().int().positive(),
8
+ downPaymentCents: z.coerce.number().int().nonnegative().nullable().optional(),
9
+ renewable: flag,
10
+ };
11
+ export const nativeTermInput = z.object(nativeTermFields).strict();
12
+ export const nativeTermsInput = z.preprocess((value) => {
13
+ if (typeof value !== "string")
14
+ return value;
15
+ try {
16
+ return JSON.parse(value);
17
+ }
18
+ catch {
19
+ return value;
20
+ }
21
+ }, z.array(nativeTermInput));
22
+ export const listingWriteControls = {
23
+ confirm: z.boolean().optional(),
24
+ idempotencyKey: z.string().min(1).max(200),
25
+ };
26
+ export const nativeListingCreateInput = z
27
+ .object({
28
+ organizationId: z.string().uuid().nullable().optional(),
29
+ kind: z.enum(["primary", "secondary"]).default("primary"),
30
+ gpuSlug: z.string().min(1),
31
+ gpusPerNode: z.coerce.number().int().positive(),
32
+ capacityNodeCount: z.coerce.number().int().positive(),
33
+ facilityId: z.string().uuid(),
34
+ provisioningType: z.enum(["bare-metal", "virtual-machine"]),
35
+ state: z.enum(["draft", "available"]),
36
+ availableAt: z.iso.datetime({ offset: true }).nullable().optional(),
37
+ availableUntil: z.iso.datetime({ offset: true }).nullable().optional(),
38
+ daysToRfs: z.coerce.number().int().nonnegative().optional(),
39
+ cpu: z.string().optional(),
40
+ ram: z.string().optional(),
41
+ storage: z.string().optional(),
42
+ fabricType: z.string().optional(),
43
+ internet: z.string().optional(),
44
+ terms: nativeTermsInput,
45
+ ...listingWriteControls,
46
+ })
47
+ .strict();
48
+ export const nativeListingUpdateFields = {
49
+ state: z.enum(["draft", "available", "delisted"]).optional(),
50
+ nodeCount: z.coerce.number().int().positive().optional(),
51
+ gpuSlug: z.string().min(1).optional(),
52
+ gpusPerNode: z.coerce.number().int().positive().optional(),
53
+ facilityId: z.string().uuid().optional(),
54
+ provisioningType: z.enum(["bare-metal", "virtual-machine"]).optional(),
55
+ availableAt: z.iso.datetime({ offset: true }).optional(),
56
+ availableUntil: z.iso.datetime({ offset: true }).optional(),
57
+ daysToRfs: z.coerce.number().int().nonnegative().optional(),
58
+ cpu: z.string().optional(),
59
+ ram: z.string().optional(),
60
+ storage: z.string().optional(),
61
+ fabricType: z.string().optional(),
62
+ internet: z.string().optional(),
63
+ };
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ export const organizationInviteInput = z.object({
4
+ organization_id: z.uuid(),
5
+ email: z.string().trim().toLowerCase().pipe(z.email()),
6
+ });
7
+ export const organizationInviteCapability = defineCapability({
8
+ id: "organization.invite",
9
+ domain: "users",
10
+ roles: ["admin"],
11
+ description: "Email an invitation to an existing organization. Resolve its UUID with organization_list first; ask the operator to choose if matches are ambiguous. The recipient may already belong to another organization. Send directly when the admin requests the invitation.",
12
+ mutation: "immediate",
13
+ http: { method: "POST", path: "/v1/staff/invites" },
14
+ cli: {
15
+ command: ["admin", "organizations", "invite"],
16
+ vnext: {
17
+ audience: "admin",
18
+ category: "resource",
19
+ command: ["admin", "organizations", "invite"],
20
+ outcome: "Email an invitation to an existing organization.",
21
+ human: "ornn admin organizations invite --organization UUID --email EMAIL",
22
+ automation: "ornn admin organizations invite --organization UUID --email EMAIL --format json",
23
+ acceptedReferences: ["organization UUID from admin organizations list"],
24
+ related: ["admin organizations list"],
25
+ },
26
+ },
27
+ slack: {
28
+ parameters: {
29
+ type: "object",
30
+ properties: {
31
+ organization_id: {
32
+ type: "string",
33
+ format: "uuid",
34
+ description: "Exact organization UUID from organization_list.",
35
+ },
36
+ email: {
37
+ type: "string",
38
+ format: "email",
39
+ description: "Address to receive the invitation.",
40
+ },
41
+ },
42
+ required: ["organization_id", "email"],
43
+ },
44
+ },
45
+ input: organizationInviteInput,
46
+ execute: async (ctx, input) => {
47
+ const parsed = organizationInviteInput.parse(input);
48
+ return ctx.fetch("/v1/staff/invites", {
49
+ method: "POST",
50
+ body: { ...parsed, send_email: true },
51
+ });
52
+ },
53
+ });
@@ -1,49 +1,159 @@
1
1
  import { z } from "zod";
2
2
  import { defineCapability } from "../capability.js";
3
- function queryPath(path, params) {
4
- const parts = [];
5
- for (const [key, value] of Object.entries(params)) {
6
- if (value?.trim())
7
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.trim())}`);
3
+ const directoryLimit = z.number().int().min(1).max(100).default(25);
4
+ const directoryOffset = z.number().int().nonnegative().optional();
5
+ export const organizationListInput = z.object({
6
+ q: z.string().trim().optional().describe("Company name, contact/member name, or email."),
7
+ role: z.enum(["consumer", "supplier"]).optional(),
8
+ approved: z.boolean().optional(),
9
+ ids: z.array(z.uuid()).min(1).max(100).optional(),
10
+ limit: directoryLimit,
11
+ offset: directoryOffset,
12
+ });
13
+ export const usersListInput = z.object({
14
+ q: z.string().trim().optional().describe("Person name, email, or company name."),
15
+ organization_id: z.uuid().optional(),
16
+ limit: directoryLimit,
17
+ offset: directoryOffset,
18
+ });
19
+ function directoryPath(input) {
20
+ const params = [
21
+ `limit=${input.limit}`,
22
+ `offset=${input.offset}`,
23
+ "sort=name",
24
+ "direction=asc",
25
+ ];
26
+ if (input.q)
27
+ params.push(`q=${encodeURIComponent(input.q)}`);
28
+ if (input.role)
29
+ params.push(`role=${encodeURIComponent(input.role)}`);
30
+ if (input.approved !== undefined) {
31
+ params.push(`approved=${input.approved}`);
8
32
  }
9
- return parts.length ? `${path}?${parts.join("&")}` : path;
33
+ if (input.ids)
34
+ params.push(`ids=${encodeURIComponent(input.ids.join(","))}`);
35
+ return `/v1/organizations?${params.join("&")}`;
36
+ }
37
+ function directoryMembers(organization) {
38
+ return organization.members ?? [];
39
+ }
40
+ function organizationMatchesQuery(organization, query) {
41
+ return (organization.name.toLowerCase().includes(query) ||
42
+ organization.id.toLowerCase() === query ||
43
+ [
44
+ organization.company_profile?.company_name,
45
+ organization.company_profile?.contact_name,
46
+ organization.company_profile?.contact_email,
47
+ organization.company_profile?.work_email,
48
+ ].some((value) => value?.toLowerCase().includes(query)));
10
49
  }
11
- const usersQueryInput = z.object({
12
- name: z.string().optional(),
13
- email: z.string().optional(),
14
- organization: z.string().optional(),
15
- q: z.string().optional(),
16
- max: z.number().int().positive().max(1000).optional(),
17
- limit: z.number().int().positive().max(1000).optional(),
50
+ export const organizationListCapability = defineCapability({
51
+ id: "organization.list",
52
+ domain: "users",
53
+ roles: ["admin"],
54
+ description: "List organizations from the Authorization directory. Search by company name, contact/member name, or email; filter by role, approval, or UUIDs. Use the organization id as organizationId when recording downtime, and ask the user to choose if matches are ambiguous.",
55
+ mutation: "read",
56
+ http: { method: "GET", path: "/v1/organizations" },
57
+ slack: {
58
+ parameters: {
59
+ type: "object",
60
+ properties: {
61
+ q: {
62
+ type: "string",
63
+ description: "Company name, contact/member name, or email.",
64
+ },
65
+ role: { type: "string", enum: ["consumer", "supplier"] },
66
+ approved: { type: "boolean" },
67
+ ids: {
68
+ type: "array",
69
+ items: { type: "string", format: "uuid" },
70
+ minItems: 1,
71
+ maxItems: 100,
72
+ },
73
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 25 },
74
+ offset: { type: "integer", minimum: 0 },
75
+ },
76
+ },
77
+ },
78
+ input: organizationListInput,
79
+ execute: async (ctx, input) => {
80
+ const parsed = organizationListInput.parse(input);
81
+ const limit = parsed.limit;
82
+ const offset = parsed.offset ?? 0;
83
+ const rows = (await ctx.fetch(directoryPath({ ...parsed, limit, offset }), { method: "GET" }));
84
+ return {
85
+ organizations: rows.map((organization) => ({
86
+ id: organization.id,
87
+ name: organization.name,
88
+ role: organization.role,
89
+ approved: organization.approved,
90
+ members: directoryMembers(organization).map((member) => ({
91
+ user_id: member.user_id,
92
+ email: member.email,
93
+ name: member.name ?? null,
94
+ })),
95
+ })),
96
+ ...(rows.length === limit ? { next_offset: offset + rows.length } : {}),
97
+ };
98
+ },
18
99
  });
19
- export const usersQueryCapability = defineCapability({
20
- id: "users.query",
100
+ export const usersListCapability = defineCapability({
101
+ id: "users.list",
21
102
  domain: "users",
22
103
  roles: ["admin"],
23
- description: "Admin-only directory search. Filter by name, email, or organization. Optional max (default 100, cap 1000). Returns matching users and organizations.",
104
+ description: "List users who belong to an organization, from the Authorization directory. Search by person name, email, or company name; use organization_id to restrict results to one organization.",
24
105
  mutation: "read",
25
- http: { method: "GET", path: "/internal/users" },
106
+ http: { method: "GET", path: "/v1/organizations" },
26
107
  slack: {
27
108
  parameters: {
28
109
  type: "object",
29
110
  properties: {
30
- name: { type: "string" },
31
- email: { type: "string" },
32
- organization: { type: "string" },
33
- max: { type: "integer", description: "Maximum rows to return (1-1000)." },
111
+ q: { type: "string", description: "Person name, email, or company name." },
112
+ organization_id: { type: "string", format: "uuid" },
113
+ limit: {
114
+ type: "integer",
115
+ minimum: 1,
116
+ maximum: 100,
117
+ default: 25,
118
+ description: "Maximum number of organizations to search.",
119
+ },
120
+ offset: { type: "integer", minimum: 0 },
34
121
  },
35
122
  },
36
123
  },
37
- input: usersQueryInput,
124
+ input: usersListInput,
38
125
  execute: async (ctx, input) => {
39
- const parsed = usersQueryInput.parse(input);
40
- const q = [parsed.q, parsed.name, parsed.email, parsed.organization].find((value) => value?.trim());
41
- return ctx.fetch(queryPath("/internal/users", {
42
- q,
43
- email: parsed.email,
44
- organization: parsed.organization,
45
- limit: String(parsed.max ?? parsed.limit ?? 100),
46
- }), { method: "GET" });
126
+ const parsed = usersListInput.parse(input);
127
+ const limit = parsed.limit;
128
+ const offset = parsed.offset ?? 0;
129
+ const rows = (await ctx.fetch(directoryPath({
130
+ limit,
131
+ offset,
132
+ q: parsed.q,
133
+ ids: parsed.organization_id ? [parsed.organization_id] : undefined,
134
+ }), { method: "GET" }));
135
+ const q = parsed.q?.toLowerCase();
136
+ const users = rows.flatMap((organization) => {
137
+ const members = directoryMembers(organization);
138
+ const matchesOrganization = q ? organizationMatchesQuery(organization, q) : false;
139
+ return members
140
+ .filter((member) => !q ||
141
+ matchesOrganization ||
142
+ (member.name ?? "").toLowerCase().includes(q) || member.email.toLowerCase().includes(q))
143
+ .map((member) => ({
144
+ user_id: member.user_id,
145
+ email: member.email,
146
+ name: member.name ?? null,
147
+ organization_id: organization.id,
148
+ organization_name: organization.name,
149
+ organization_role: organization.role,
150
+ approved: organization.approved,
151
+ }));
152
+ });
153
+ return {
154
+ users,
155
+ ...(rows.length === limit ? { next_offset: offset + rows.length } : {}),
156
+ };
47
157
  },
48
158
  });
49
- export const userCapabilities = [usersQueryCapability];
159
+ export const userCapabilities = [organizationListCapability, usersListCapability];
@@ -16,6 +16,7 @@ import { mountCapabilities } from "./capabilities/mounts.js";
16
16
  import { identityCapabilities } from "./capabilities/identity.js";
17
17
  import { listingCapabilities } from "./capabilities/listings.js";
18
18
  import { observabilityCapabilities } from "./capabilities/observability.js";
19
+ import { organizationInviteCapability } from "./capabilities/organization-invites.js";
19
20
  import { networkCapabilities } from "./capabilities/networks.js";
20
21
  import { volumeCapabilities } from "./capabilities/volumes.js";
21
22
  import { userCapabilities } from "./capabilities/users.js";
@@ -41,7 +42,7 @@ export const catalog = [
41
42
  ...bindAdminEnrollmentCapabilities(bindAdminCapabilities([
42
43
  ...cliShellCapabilities,
43
44
  ...identityCapabilities,
44
- ...listingCapabilities,
45
+ ...listingCapabilities.filter((capability) => capability.id !== "listings.create"),
45
46
  ...commerceMarketCapabilities,
46
47
  ...marketCapabilities,
47
48
  ...computeCapabilities,
@@ -60,8 +61,9 @@ export const catalog = [
60
61
  ...adminNodeLifecycleCapabilities,
61
62
  ])),
62
63
  ...adminCapabilities.filter((capability) => capability.id === "nodes.health"),
63
- ...adminCapacityReservationCapabilities.filter((capability) => capability.id !== "listings.create"),
64
+ ...adminCapacityReservationCapabilities,
64
65
  ...adminNodeDiagnosticCapabilities,
65
66
  ...adminEnrollmentCapabilities,
67
+ organizationInviteCapability,
66
68
  ]),
67
69
  ];
@@ -231,7 +231,6 @@ export const LEGACY_CLI_MIGRATIONS = [
231
231
  { legacy: ["tokens", "list"], replacement: "ornn admin enrollment-tokens list" },
232
232
  { legacy: ["tokens", "create"], replacement: "ornn admin enrollment-tokens create" },
233
233
  { legacy: ["tokens", "revoke"], replacement: "ornn admin enrollment-tokens revoke" },
234
- { legacy: ["users", "query"], replacement: "ornn admin users search" },
235
234
  { legacy: ["node", "enroll"], replacement: "ornn admin nodes enroll" },
236
235
  { legacy: ["node", "list"], replacement: "ornn admin nodes list" },
237
236
  { legacy: ["node", "health"], replacement: "ornn admin nodes health" },
@@ -247,7 +246,7 @@ export const LEGACY_CLI_MIGRATIONS = [
247
246
  { legacy: ["operators"], replacement: "ornn admin suppliers list" },
248
247
  { legacy: ["facilities"], replacement: "ornn admin facilities list" },
249
248
  { legacy: ["tokens"], replacement: "ornn admin enrollment-tokens list" },
250
- { legacy: ["users"], replacement: "ornn admin users search" },
249
+ { legacy: ["users"], replacement: "ornn admin users list" },
251
250
  { legacy: ["node"], replacement: "ornn admin nodes list" },
252
251
  { legacy: ["cache", "purge"], replacement: "ornn resources" },
253
252
  { legacy: ["api"], replacement: "ornn resources" },