{
  "connector": {
    "key": "acme-projects",
    "kind": "http",
    "name": "Acme Projects",
    "sdk": ">=2",
    "version": 1
  },
  "files": {
    "CHANGELOG.md": "# Changelog\n\n## 2026-09-27\n\n- First version: users and teams, synced; users created, updated, deactivated and reactivated;\n  team membership with its role added and removed.\n",
    "README.md": "# Acme Projects\n\nSyncs Acme Projects users and teams, provisions users, and adds and removes team memberships.\n",
    "handlers/connection/testConnection.ts": "import type { Handler } from \"../../types.gen.ts\";\nimport { ConnectorSdkError } from \"@owlie/connectors/sdk/v2\";\n\n/** Reads one user: proves the subdomain, the token and permission to read users. */\nconst testConnection: Handler<\"connection.testConnection\"> = async (_input, ctx) => {\n  try {\n    await ctx.http.get(\"/users\", { ...ctx.options, query: { limit: 1 } });\n    return \"connected\";\n  } catch (error) {\n    throw new ConnectorSdkError(error, {\n      code: \"CONFIGURATION_ERROR\",\n      message: \"Acme Projects connection test failed\",\n      customerMessage: \"Acme Projects could not verify the subdomain and API token.\",\n      handledAt: \"connector.invocation-context\",\n    });\n  }\n};\n\nexport default testConnection;\n",
    "handlers/connection/validateConfig.ts": "import type { Handler } from \"../../types.gen.ts\";\nimport { isRecord } from \"@owlie/connectors/sdk/v2\";\n\n/** The schema's pattern refuses a full host too; this answers with a message that says what to enter. */\nconst validateConfig: Handler<\"connection.validateConfig\"> = async (input) => {\n  const config = isRecord(input) && isRecord(input.config) ? input.config : {};\n  const subdomain = config.subdomain;\n  if (typeof subdomain !== \"string\" || !/^[a-z0-9-]+$/.test(subdomain)) {\n    return { valid: false, errors: [\"Enter only the part before .acmeprojects.example, for example acme.\"] };\n  }\n  return { valid: true };\n};\n\nexport default validateConfig;\n",
    "handlers/grants/team_member/add.ts": "import type { AcmeMember } from \"../../../lib/types.ts\";\nimport type { Handler } from \"../../../types.gen.ts\";\nimport { isAlreadyExists } from \"@owlie/connectors/sdk/v2\";\nimport { resolveTeamId, resolveUserId } from \"../../../lib/lookups.ts\";\n\n/**\n * POST adds the membership. A 409 `already_member` (mapped to ALREADY_MEMBER by lib/map-error.ts)\n * says a membership exists, but not with which role, so read it: the same role is already present,\n * a different role is changed in place, since a user holds one role per team.\n */\nconst add: Handler<\"grant.team_member.add\"> = async (input, ctx) => {\n  const userId = await resolveUserId(ctx, input.principal.identifiers);\n  const teamId = resolveTeamId(ctx, input.target.identifiers);\n  const role = input.qualifiers.role;\n  const members = `/teams/${encodeURIComponent(teamId)}/members`;\n  try {\n    await ctx.http.post(members, { ...ctx.options, json: { user_id: userId, role } });\n  } catch (error) {\n    if (!isAlreadyExists(error)) throw error;\n    const member = `${members}/${encodeURIComponent(userId)}`;\n    const existing = await ctx.http.get<AcmeMember>(member, ctx.options);\n    if (existing.data.role === role) return ctx.grant.alreadyPresent();\n    await ctx.http.patch(member, { ...ctx.options, json: { role } });\n  }\n};\n\nexport default add;\n",
    "handlers/grants/team_member/remove.ts": "import type { Handler } from \"../../../types.gen.ts\";\nimport { applicationCodeOf } from \"@owlie/connectors/sdk/v2\";\nimport { resolveTeamId, resolveUserId } from \"../../../lib/lookups.ts\";\n\n/**\n * DELETE removes the membership. lib/map-error.ts turns Acme's documented 404 `not_a_member` into\n * NOT_MEMBER, the only answer that proves the membership is gone. Any other 404 (an unknown team or\n * user, a mistyped path) keeps its NOT_FOUND code and fails the remove.\n */\nconst remove: Handler<\"grant.team_member.remove\"> = async (input, ctx) => {\n  const userId = await resolveUserId(ctx, input.principal.identifiers);\n  const teamId = resolveTeamId(ctx, input.target.identifiers);\n  try {\n    await ctx.http.delete(`/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(userId)}`, ctx.options);\n  } catch (error) {\n    if (applicationCodeOf(error) === \"NOT_MEMBER\") return ctx.grant.alreadyAbsent();\n    throw error;\n  }\n};\n\nexport default remove;\n",
    "handlers/team/fetchAll.ts": "import type { Handler, TeamRecord } from \"../../types.gen.ts\";\n\nconst fetchAll: Handler<\"team.fetchAll\"> = (options, ctx) => ctx.paginate<TeamRecord>({ path: \"/teams\", options });\n\nexport default fetchAll;\n",
    "handlers/team/fetchOne.ts": "import type { Handler, TeamRecord } from \"../../types.gen.ts\";\nimport { isNotFound } from \"@owlie/connectors/sdk/v2\";\nimport { resolveTeamId } from \"../../lib/lookups.ts\";\n\nconst fetchOne: Handler<\"team.fetchOne\"> = async (identifiers, ctx) => {\n  try {\n    const id = resolveTeamId(ctx, identifiers);\n    const response = await ctx.http.get<TeamRecord>(`/teams/${encodeURIComponent(id)}`, ctx.options);\n    return { record: response.data };\n  } catch (error) {\n    if (isNotFound(error)) return { record: null };\n    throw error;\n  }\n};\n\nexport default fetchOne;\n",
    "handlers/team/fetchRelationships.ts": "import type { AcmeMember } from \"../../lib/types.ts\";\nimport type { Handler } from \"../../types.gen.ts\";\n\nconst fetchRelationships: Handler<\"team.fetchRelationships\"> = async (teamId, options, ctx) => {\n  const page = await ctx.paginate<AcmeMember>({ path: `/teams/${encodeURIComponent(teamId)}/members`, options });\n  return {\n    ...page,\n    records: page.records.map((member) => ({\n      edgeKey: \"team_member\",\n      sourceEntityKey: \"user\",\n      sourceExternalId: member.user_id,\n      targetEntityKey: \"team\",\n      targetExternalId: teamId,\n      attrs: { role: member.role },\n    })),\n  };\n};\n\nexport default fetchRelationships;\n",
    "handlers/user/create.ts": "import type { Handler, UserRecord } from \"../../types.gen.ts\";\n\nconst create: Handler<\"user.create\"> = async (input, ctx) => {\n  const response = await ctx.http.post<UserRecord>(\"/users\", {\n    ...ctx.options,\n    json: { email: input.email, first_name: input.first_name, last_name: input.last_name },\n  });\n  ctx.validate.resourceSchema(\"user\", response.data, { mode: \"warn\", operation: \"create\", subject: \"user response\" });\n  return response.data;\n};\n\nexport default create;\n",
    "handlers/user/disable.ts": "import type { Handler } from \"../../types.gen.ts\";\nimport { resolveUserId } from \"../../lib/lookups.ts\";\n\nconst disable: Handler<\"user.disable\"> = async (identifiers, ctx) => {\n  const id = await resolveUserId(ctx, identifiers);\n  await ctx.http.post(`/users/${encodeURIComponent(id)}/deactivate`, ctx.options);\n};\n\nexport default disable;\n",
    "handlers/user/enable.ts": "import type { Handler } from \"../../types.gen.ts\";\nimport { resolveUserId } from \"../../lib/lookups.ts\";\n\nconst enable: Handler<\"user.enable\"> = async (identifiers, ctx) => {\n  const id = await resolveUserId(ctx, identifiers);\n  await ctx.http.post(`/users/${encodeURIComponent(id)}/reactivate`, ctx.options);\n};\n\nexport default enable;\n",
    "handlers/user/fetchAll.ts": "import type { Handler, UserRecord } from \"../../types.gen.ts\";\n\nconst fetchAll: Handler<\"user.fetchAll\"> = async (options, ctx) => {\n  const page = await ctx.paginate<UserRecord>({ path: \"/users\", options });\n  for (const record of page.records) {\n    ctx.validate.resourceSchema(\"user\", record, { mode: \"warn\", operation: \"fetchAll\", subject: \"user record\" });\n  }\n  return page;\n};\n\nexport default fetchAll;\n",
    "handlers/user/fetchOne.ts": "import type { Handler, UserRecord } from \"../../types.gen.ts\";\nimport { isNotFound } from \"@owlie/connectors/sdk/v2\";\nimport { resolveUserId } from \"../../lib/lookups.ts\";\n\nconst fetchOne: Handler<\"user.fetchOne\"> = async (identifiers, ctx) => {\n  try {\n    const id = await resolveUserId(ctx, identifiers);\n    const response = await ctx.http.get<UserRecord>(`/users/${encodeURIComponent(id)}`, ctx.options);\n    return {\n      record: response.data,\n      metadata: { lifecycleState: response.data.status === \"deactivated\" ? \"DISABLED\" : \"ACTIVE\" },\n    };\n  } catch (error) {\n    if (isNotFound(error)) return { record: null };\n    throw error;\n  }\n};\n\nexport default fetchOne;\n",
    "handlers/user/update.ts": "import type { Handler, UserRecord } from \"../../types.gen.ts\";\nimport { resolveUserId } from \"../../lib/lookups.ts\";\n\nconst update: Handler<\"user.update\"> = async (identifiers, input, ctx) => {\n  const id = await resolveUserId(ctx, identifiers);\n  const response = await ctx.http.patch<UserRecord>(`/users/${encodeURIComponent(id)}`, {\n    ...ctx.options,\n    json: input,\n  });\n  ctx.validate.resourceSchema(\"user\", response.data, { mode: \"warn\", operation: \"update\", subject: \"user response\" });\n  return response.data;\n};\n\nexport default update;\n",
    "lib/lookups.ts": "import type { Context } from \"../types.gen.ts\";\n\n/** The user's Acme id: taken as given when present, else looked up by email. A miss throws NOT_FOUND. */\nexport async function resolveUserId(ctx: Context, identifiers: { id?: string; email?: string }): Promise<string> {\n  if (identifiers.id) return identifiers.id;\n  if (!identifiers.email) throw ctx.errors.invalidInput(\"An Acme Projects user needs an id or an email\");\n  const response = await ctx.http.get<{ data: { id: string }[] }>(\"/users\", {\n    ...ctx.options,\n    query: { email: identifiers.email, limit: 1 },\n  });\n  const user = response.data.data[0];\n  if (!user) throw ctx.errors.notFound(\"User\", `No Acme Projects user has the email ${identifiers.email}`);\n  return user.id;\n}\n\n/** The team's Acme id. Teams are addressed by id only. */\nexport function resolveTeamId(ctx: Context, identifiers: { id?: string }): string {\n  if (!identifiers.id) throw ctx.errors.invalidInput(\"An Acme Projects team needs its id\");\n  return identifiers.id;\n}\n",
    "lib/map-error.ts": "import type { MapError } from \"../types.gen.ts\";\nimport { ConnectorApplicationError, isHttpResponseError } from \"@owlie/connectors/sdk/v2\";\n\n/**\n * Acme Projects documents two membership answers: `{ \"error\": { \"code\": \"already_member\" } }` (409)\n * when an add finds the membership, and `{ \"error\": { \"code\": \"not_a_member\" } }` (404) when a remove\n * does not. Only these become ALREADY_MEMBER and NOT_MEMBER; every other error keeps its own code.\n */\nexport const mapError: MapError = (error) => {\n  if (!isHttpResponseError(error)) return;\n  if (error.details?.providerCode === \"already_member\") {\n    return new ConnectorApplicationError({\n      code: \"ALREADY_MEMBER\",\n      resource: \"MEMBERSHIP\",\n      message: \"Already a member\",\n    });\n  }\n  if (error.details?.providerCode === \"not_a_member\") {\n    return new ConnectorApplicationError({ code: \"NOT_MEMBER\", resource: \"MEMBERSHIP\", message: \"Not a member\" });\n  }\n};\n",
    "lib/types.ts": "/** Acme Projects' list responses: `{ \"data\": [...], \"next_cursor\": \"...\" }`. */\nexport type AcmeMember = { user_id: string; role: \"member\" | \"admin\" };\n"
  },
  "format": 1,
  "manifest": {
    "key": "acme-projects",
    "version": 1,
    "name": "Acme Projects",
    "description": "Acme Projects users and teams, synced and provisioned, with team membership writes.",
    "icon": "@acmeprojects.example",
    "kind": "http",
    "provides": ["sync", "provision"],
    "pagination_type": "cursor",
    "config": {
      "schema": {
        "type": "object",
        "required": ["subdomain"],
        "properties": {
          "subdomain": {
            "type": "string",
            "minLength": 1,
            "pattern": "^[a-z0-9-]+$",
            "title": "Subdomain",
            "description": "Your Acme Projects subdomain, for example acme in acme.acmeprojects.example.",
            "x-suffix": ".acmeprojects.example"
          }
        },
        "additionalProperties": false
      }
    },
    "auth": [
      {
        "key": "api-token",
        "title": "API token",
        "description": "An Acme Projects admin API token.",
        "config": {
          "schema": {
            "type": "object",
            "required": ["api_token"],
            "properties": {
              "api_token": {
                "type": "string",
                "minLength": 1,
                "title": "API token",
                "format": "password",
                "x-sensitive": true
              }
            },
            "additionalProperties": false
          }
        },
        "apply": {
          "header": {
            "Authorization": {
              "expr": "'Bearer ' + config.api_token"
            }
          },
          "base_url": {
            "expr": "'https://' + config.subdomain + '.acmeprojects.example/api/v2'"
          }
        }
      }
    ],
    "egress": {
      "manual": ["acmeprojects.example"]
    },
    "entities": {
      "user": {
        "name": "User",
        "description": "An Acme Projects user. The account entity.",
        "is_account": true,
        "identifier_paths": ["id", "email"],
        "sync_support": {
          "supports_incremental": false,
          "supports_deleted": false
        },
        "schema": {
          "type": "object",
          "properties": {
            "id": {
              "type": "string",
              "title": "User ID",
              "readOnly": true,
              "x-identifier": true,
              "x-provider-stable-id": true
            },
            "email": {
              "type": "string",
              "format": "email",
              "title": "Email",
              "x-identifier": true,
              "x-provisioning": {
                "requiredOn": ["PROVISION"]
              }
            },
            "first_name": {
              "type": "string",
              "title": "First name"
            },
            "last_name": {
              "type": "string",
              "title": "Last name"
            },
            "status": {
              "type": "string",
              "enum": ["active", "deactivated"],
              "title": "Status",
              "readOnly": true
            },
            "created_at": {
              "type": "string",
              "format": "date-time",
              "title": "Created",
              "readOnly": true
            }
          }
        },
        "sync_profile": {
          "target": "RESOURCE",
          "matchingRules": [
            {
              "identityField": "email",
              "externalExpression": "email"
            }
          ],
          "is_active": "status != 'deactivated'"
        },
        "provisioning_profile": {
          "version": 1,
          "operations": {
            "PROVISION": {
              "provision_mode": "direct",
              "baseline": {
                "attributes": {
                  "email": {
                    "type": "expression",
                    "value": "identity.email"
                  },
                  "first_name": {
                    "type": "expression",
                    "value": "identity.first_name"
                  },
                  "last_name": {
                    "type": "expression",
                    "value": "identity.last_name"
                  }
                },
                "grants": []
              },
              "before": [],
              "after": []
            },
            "REVOKE": {
              "remove_managed_grants": true,
              "lifecycle_state_on_revoke": "DISABLED",
              "before": [],
              "after": []
            },
            "DISABLE": {
              "before": [],
              "after": []
            },
            "ENABLE": {
              "before": [],
              "after": []
            },
            "UPDATE_ATTRIBUTES": {
              "before": [],
              "after": []
            },
            "ADD_GRANTS": {
              "before": [],
              "after": []
            },
            "REMOVE_GRANTS": {
              "before": [],
              "after": []
            },
            "SET_GRANTS": {
              "before": [],
              "after": []
            }
          }
        }
      },
      "team": {
        "name": "Team",
        "description": "An Acme Projects team.",
        "identifier_paths": ["id"],
        "sync_support": {
          "supports_incremental": false,
          "supports_deleted": false
        },
        "schema": {
          "type": "object",
          "properties": {
            "id": {
              "type": "string",
              "title": "Team ID",
              "readOnly": true,
              "x-identifier": true
            },
            "name": {
              "type": "string",
              "title": "Name"
            },
            "description": {
              "type": "string",
              "title": "Description"
            }
          }
        }
      }
    },
    "grant_kinds": [
      {
        "key": "team_member",
        "source": "user",
        "target": "team",
        "discovered_via": "team",
        "fetch_mode": "per-entity",
        "writable": true,
        "qualifiers": [
          {
            "name": "role",
            "schema": "enum",
            "values": ["member", "admin"],
            "order": ["member", "admin"],
            "default": "member",
            "required": true
          }
        ]
      }
    ]
  },
  "provenance": {
    "source": "Owlie documentation example"
  }
}
